Kora облачно ориентированный серверный фреймворк написанный на Java для написания Java / Kotlin приложений с упором на производительность, эффективность, прозрачность сделанный выходцами из Т-Банк / Тинькофф

Kora is a cloud-oriented server-side Java framework for writing Java / Kotlin applications with a focus on performance, efficiency and transparency

Skip to content
V1 V2

File Upload and Storage with S3

This guide introduces S3-compatible file storage in a Kora HTTP application. It covers how upload and download routes receive multipart data, how Kora's declarative S3 client stores objects in a bucket, and how application services keep file metadata separate from object storage concerns. You will also see how local MinIO infrastructure gives the same API shape as production-style S3 storage.

If you want to check your progress along the way, use the finished working example: Kora Java S3 App.

If you want to check your progress along the way, use the finished working example: Kora Kotlin S3 App.

What You'll Build

In this guide you'll extend the HTTP server application with a small file storage API backed by S3-compatible storage.

By the end, your app will support:

  • multipart file upload through POST /files/upload
  • file listing through GET /files
  • file download through GET /files/{fileId}
  • file deletion through DELETE /files/{fileId}
  • a declarative S3 client built with @S3.Client
  • a startup component that creates the bucket through the AWS SDK client
  • local development and tests against MinIO as an S3-compatible backend

What You'll Need

  • JDK 25 or later
  • Gradle 9+
  • Docker for local MinIO runs and container-based tests
  • A text editor or IDE
  • Completed HTTP Server Guide

Prerequisites

Required: Complete HTTP Server Advanced Guide

This guide assumes you have completed HTTP Server Advanced Guide and already have a Kora application with Application, UserController, DataController, shared HTTP server wiring, and familiarity with FormMultipart, JSON responses, and Kora controllers.

If you haven't completed the advanced HTTP server guide yet, do that first, because this guide extends the existing DataController area with file storage behavior instead of rebuilding the HTTP surface.

Overview

Amazon S3-compatible storage is object storage, not a relational database and not a local filesystem. It stores objects by bucket and key, and it is designed for binary content such as files, images, documents, backups, and exported data. Applications usually keep metadata in their own domain model and store the file bytes in object storage.

That distinction matters because object storage has a different access model. You do not update individual columns or query objects with SQL. You put an object by key, get it by key, list keys, and delete keys. The application must decide how those storage operations appear through HTTP.

What Is S3

S3 is an object storage API and ecosystem standard that started with Amazon S3 and is now supported by many compatible systems, including MinIO.

Unlike a relational database, S3 is not designed for structured queries, joins, transactions, or filtering business records. Instead, it is designed to store and retrieve large binary objects such as files, images, videos, exported reports, backups, and generated documents.

In practical terms, S3 usually plays a role next to a database, not instead of a database:

  • the database stores structured business data such as users, orders, permissions, and references to files
  • S3 storage stores the file content itself, usually by object key

That split is very common in real systems because it gives you a better operational model:

  • databases stay focused on relational business data
  • large files do not bloat database tables and backups
  • file storage can scale independently from the main application database
  • file downloads and uploads can use storage-oriented tooling and infrastructure

Typical real-world S3 scenarios include:

  • user-uploaded avatars, attachments, PDFs, and spreadsheets
  • product images and media catalogs
  • generated invoices, reports, and exports
  • log archives and backup snapshots
  • intermediate files for analytics and machine learning pipelines
  • public or private static assets served through CDN layers

Why teams often prefer S3-style storage for files:

  • it scales well for large object counts and large object sizes
  • the access model is simple: store by key, fetch by key, delete by key, list by prefix
  • object metadata and content type travel naturally with the file
  • cloud and self-hosted ecosystems already provide mature tooling around S3-compatible storage

Object Storage Concepts

The core S3 concepts in this guide are:

  • bucket: a named container for objects
  • key: the object's identifier inside a bucket
  • object body: the file content
  • metadata: optional information about the object
  • content type: the media type clients use when downloading or displaying the object

Unlike a database row, an object is usually read and written as a stream of bytes. That makes upload and download endpoints different from JSON CRUD routes.

HTTP Upload and Download Boundaries

File APIs often combine HTTP and storage concerns. The HTTP layer receives multipart data, exposes download responses, and maps delete/list operations into routes. The S3 client handles object operations such as put, get, list, and delete. Keeping that boundary clear prevents controller code from becoming a storage implementation.

This guide focuses on a small file storage API:

  • upload a file from a multipart request
  • list stored objects
  • download an object by key
  • delete an object by key

Two Independent S3 Artifacts

Kora 2.0 ships two S3 artifacts. They are independent, they do not depend on each other, and choosing between them is the first decision this guide makes.

Artifact What it publishes Reach for it when
io.koraframework.experimental:s3-client-kora Declarative @S3.Client interfaces, S3Client You want typed, generated storage operations and no AWS SDK on the classpath
io.koraframework:s3-client-aws software.amazon.awssdk.services.s3.S3Client You need the full AWS SDK surface: bucket administration, copying, presigned URLs, ACLs

The declarative client is built on Kora's own HTTP client and does not depend on the AWS SDK at all. It covers the object operations an application performs at request time — put, get, head, list, delete — and nothing else. Bucket administration is deliberately outside its contract.

The AWS artifact is a thin wrapper: it publishes the real AWS SDK S3Client as a graph component, configured from Kora config and running over Kora's HTTP client. Everything the SDK can do, it can do.

This guide uses both, which is a common production shape: the declarative client for the request path, and the AWS SDK client once at startup to make sure the bucket exists. See Using both artifacts for the full picture.

There is no s3-client-minio artifact

Kora 2.0 does not ship a MinIO-specific client, and neither artifact needs one. MinIO speaks the S3 API, so it is used here purely as an S3-compatible server for local runs and tests — a different statement from having a MinIO client library.

The practical flow is:

  1. add both S3 artifacts and the HTTP client module they run on
  2. configure the AWS client at s3client.aws and one declarative client at s3client.uploads
  3. declare the @S3.Client interface and point its @S3.Bucket at the configured bucket name
  4. create the bucket at startup through the AWS SDK client
  5. map multipart upload requests to object writes and download routes to object reads
  6. verify the same behavior against MinIO in tests

Local MinIO and Production Shape

MinIO is used as local S3-compatible infrastructure because it is easy to run for development and tests. The application code stays exactly the same against real S3: only the endpoint and credentials change. Tests use containers to make storage behavior repeatable.

Dependencies

We are building on the existing HTTP server app, so we add the two S3 artifacts plus the HTTP client module both of them run on.

build.gradle
dependencies {
    // ... existing dependencies ...

    implementation("io.koraframework:http-client-ok")
    implementation("io.koraframework:s3-client-aws")
    implementation("io.koraframework.experimental:s3-client-kora")
}
build.gradle.kts
dependencies {
    // ... existing dependencies ...

    implementation("io.koraframework:http-client-jdk")
    implementation("io.koraframework:s3-client-aws")
    implementation("io.koraframework.experimental:s3-client-kora")
}

Note the group ids. The declarative client is still experimental and therefore published under io.koraframework.experimental, while the AWS wrapper is a stable artifact under plain io.koraframework. Both versions come from the io.koraframework:kora-bom platform, so neither line carries a version.

An HTTP client module is mandatory for both. Neither S3 artifact opens its own sockets: the declarative client speaks S3 over Kora's HttpClient, and the AWS wrapper hands the SDK a Kora-backed SdkHttpClient. Any transport works — the Java example uses http-client-ok and the Kotlin one http-client-jdk — but one of them must be in the graph.

Modules

Now we connect both S3 modules to the existing HTTP server application.

Update src/main/java/io/koraframework/guide/s3/Application.java:

package io.koraframework.guide.s3;

import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.config.hocon.HoconConfigModule;
import io.koraframework.http.client.ok.OkHttpClientModule;
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule;
import io.koraframework.json.common.JsonModule;
import io.koraframework.logging.logback.LogbackModule;
import io.koraframework.s3.client.aws.AwsS3ClientModule;
import io.koraframework.s3.client.kora.KoraS3ClientModule;

@KoraApp
public interface Application extends
        HoconConfigModule,
        JsonModule,
        LogbackModule,
        OkHttpClientModule,
        AwsS3ClientModule,   // <----- Connected module
        KoraS3ClientModule,  // <----- Connected module
        UndertowPublicHttpServerModule {

    static void main(String[] args) {
        KoraApplication.run(ApplicationGraph::graph);
    }
}

Update src/main/kotlin/io/koraframework/guide/s3/Application.kt:

package io.koraframework.guide.s3

import io.koraframework.application.graph.KoraApplication
import io.koraframework.common.annotation.KoraApp
import io.koraframework.config.hocon.HoconConfigModule
import io.koraframework.http.client.jdk.JdkHttpClientModule
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule
import io.koraframework.json.common.JsonModule
import io.koraframework.logging.logback.LogbackModule
import io.koraframework.s3.client.aws.AwsS3ClientModule
import io.koraframework.s3.client.kora.KoraS3ClientModule

@KoraApp
interface Application :
    HoconConfigModule,
    JsonModule,
    LogbackModule,
    JdkHttpClientModule,
    AwsS3ClientModule,   // <----- Connected module
    KoraS3ClientModule,  // <----- Connected module
    UndertowPublicHttpServerModule

fun main() {
    KoraApplication.run(ApplicationGraph::graph)
}

AwsS3ClientModule publishes an S3Client bound to the fixed configuration path s3client.aws. KoraS3ClientModule publishes only the telemetry and credentials infrastructure that generated declarative clients need — each @S3.Client interface brings its own configuration path, so the module itself has no bucket or endpoint of its own.

We keep the same HTTP server modules from the previous guide and add only the S3-specific pieces.

Configuration

The app still uses the same HTTP server configuration from the previous guide. In this guide we add two independent sections, one per client.

For the full configuration reference, see S3 Client.

src/main/resources/application.conf
s3client.aws { //(1)!
  url = ${S3_URL} //(2)!
  region = "us-east-1"
  region = ${?S3_REGION}

  credentials { //(3)!
    accessKey = ${S3_ACCESS_KEY}
    secretKey = ${S3_SECRET_KEY}
  }
}

s3client.uploads { //(4)!
  endpoint = ${S3_URL} //(5)!
  region = "us-east-1"
  region = ${?S3_REGION}

  bucket = "uploads" //(6)!
  bucket = ${?S3_BUCKET}

  credentials {
    accessKey = ${S3_ACCESS_KEY}
    secretKey = ${S3_SECRET_KEY}
  }
}
  1. Fixed path for s3-client-aws. AwsS3ClientModule always reads exactly this path.
  2. The AWS client calls the endpoint key url.
  3. Both clients nest credentials in their own credentials block.
  4. Free path for the declarative client, chosen by its @S3.Client annotation.
  5. The declarative client calls the same thing endpoint.
  6. Bucket name read by @S3.Bucket(".bucket") and by the startup initializer.
src/main/resources/application.yaml
s3client:
  aws: #(1)!
    url: ${S3_URL} #(2)!
    region: ${?S3_REGION:"us-east-1"}
    credentials: #(3)!
      accessKey: ${S3_ACCESS_KEY}
      secretKey: ${S3_SECRET_KEY}
  uploads: #(4)!
    endpoint: ${S3_URL} #(5)!
    region: ${?S3_REGION:"us-east-1"}
    bucket: ${?S3_BUCKET:"uploads"} #(6)!
    credentials:
      accessKey: ${S3_ACCESS_KEY}
      secretKey: ${S3_SECRET_KEY}
  1. Fixed path for s3-client-aws. AwsS3ClientModule always reads exactly this path.
  2. The AWS client calls the endpoint key url.
  3. Both clients nest credentials in their own credentials block.
  4. Free path for the declarative client, chosen by its @S3.Client annotation.
  5. The declarative client calls the same thing endpoint.
  6. Bucket name read by @S3.Bucket(".bucket") and by the startup initializer.

Three details are easy to trip over.

The two sections are not nested inside one another and share nothing. s3client.aws is hardcoded in AwsS3ClientModule; s3client.uploads exists only because the @S3.Client annotation in the next step names it. You could just as well call it storage.files — the annotation is the single source of truth.

The endpoint key differs between them: url for the AWS client, endpoint for the declarative one. Getting this wrong produces a startup failure naming the missing key, which is the fastest way to discover the mismatch.

bucket belongs only to the declarative client's section. The AWS client has no bucket configuration at all, because in the AWS SDK the bucket is an argument to every call.

Both sections also accept addressStyle (PATH by default, which is what MinIO wants) and requestTimeout, and the declarative one adds an upload block controlling partSize, chunkSize, and singlePartUploadLimit for multipart uploads.

Declarative S3 Client

Kora's declarative S3 client works in the same spirit as its HTTP client support. That is the main concept this guide is teaching.

Create src/main/java/io/koraframework/guide/s3/s3/S3FileClient.java:

package io.koraframework.guide.s3.s3;

import io.koraframework.s3.client.kora.annotation.S3;
import io.koraframework.s3.client.kora.model.response.GetObjectResult;
import io.koraframework.s3.client.kora.model.response.ListBucketResult;

@S3.Client("s3client.uploads")
@S3.Bucket(".bucket")
public interface S3FileClient {

    @S3.Put("files/{fileId}")
    String uploadFile(String fileId, byte[] body);

    @S3.Get("files/{fileId}")
    GetObjectResult downloadFile(String fileId);

    @S3.List("files/")
    ListBucketResult listFiles();

    @S3.Delete("files/{fileId}")
    void deleteFile(String fileId);
}

Create src/main/kotlin/io/koraframework/guide/s3/s3/S3FileClient.kt:

package io.koraframework.guide.s3.s3

import io.koraframework.s3.client.kora.annotation.S3
import io.koraframework.s3.client.kora.model.response.GetObjectResult
import io.koraframework.s3.client.kora.model.response.ListBucketResult

@S3.Client("s3client.uploads")
@S3.Bucket(".bucket")
interface S3FileClient {

    @S3.Put("files/{fileId}")
    fun uploadFile(fileId: String, body: ByteArray): String

    @S3.Get("files/{fileId}")
    fun downloadFile(fileId: String): GetObjectResult

    @S3.List("files/")
    fun listFiles(): ListBucketResult

    @S3.Delete("files/{fileId}")
    fun deleteFile(fileId: String)
}

This interface is intentionally small. Each method maps almost one-to-one to a storage operation, and the annotations define the object key or key prefix.

A few important details:

  • @S3.Client("s3client.uploads") names the configuration path this client reads
  • @S3.Bucket(".bucket") names the config key holding the bucket. The leading dot makes it relative to the client's own path, so it resolves to s3client.uploads.bucket. An absolute path such as @S3.Bucket("storage.files.bucket") also works, and @S3.Bucket can instead sit on a method parameter when the bucket is chosen at runtime
  • @S3.Put("files/{fileId}") builds the final object key from the method argument, and returns the object's ETag as a String
  • @S3.Get returns a GetObjectResult, which is an HttpClientResponse: it carries headers and a body, and it must be closed
  • @S3.List("files/") limits listing to one prefix, which keeps the example deterministic, and returns a ListBucketResult record with an items() list

The body parameter accepts byte[], ByteBuffer, or an InputStream. Byte arrays are fine for the small uploads in this guide; a stream is the right choice once files get large enough that buffering them whole is a problem, and the client switches to a multipart upload past upload.singlePartUploadLimit on its own.

Compilation generates three sources next to your interface: $S3FileClient_BucketsConfig reading the bucket name, $S3FileClient_S3ClientImpl implementing the operations, and $S3FileClient_S3Module publishing the client into the graph.

For more annotation patterns such as key templates, metadata-only responses, byte ranges, and list iterators, see the S3 Client documentation.

Bucket Initialization

The declarative client can put, get, list, and delete objects, but it cannot create the bucket that holds them — bucket administration is not part of the @S3 contract. That is exactly the gap the AWS SDK client fills.

First, expose the bucket name to your own code. @S3.Bucket produces a generated class rather than an injectable component, so the initializer reads the same config path itself:

Create src/main/java/io/koraframework/guide/s3/s3/S3UploadsConfig.java:

package io.koraframework.guide.s3.s3;

import io.koraframework.config.common.annotation.ConfigSource;

@ConfigSource("s3client.uploads")
public interface S3UploadsConfig {

    String bucket();
}

Create src/main/kotlin/io/koraframework/guide/s3/s3/S3UploadsConfig.kt:

package io.koraframework.guide.s3.s3

import io.koraframework.config.common.annotation.ConfigSource

@ConfigSource("s3client.uploads")
interface S3UploadsConfig {
    fun bucket(): String
}

Then create the bucket at startup, using the S3Client that s3-client-aws published:

Create src/main/java/io/koraframework/guide/s3/s3/S3BucketInitializer.java:

package io.koraframework.guide.s3.s3;

import io.koraframework.application.graph.Lifecycle;
import io.koraframework.common.annotation.Component;
import io.koraframework.common.annotation.Root;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
import software.amazon.awssdk.services.s3.model.NoSuchBucketException;

@Root
@Component
public final class S3BucketInitializer implements Lifecycle {

    private final S3Client s3Client;
    private final S3UploadsConfig config;

    public S3BucketInitializer(S3Client s3Client, S3UploadsConfig config) {
        this.s3Client = s3Client;
        this.config = config;
    }

    @Override
    public void init() {
        var bucket = this.config.bucket();
        try {
            this.s3Client.headBucket(HeadBucketRequest.builder().bucket(bucket).build());
        } catch (NoSuchBucketException e) {
            this.s3Client.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
        }
    }

    @Override
    public void release() {}
}

Create src/main/kotlin/io/koraframework/guide/s3/s3/S3BucketInitializer.kt:

package io.koraframework.guide.s3.s3

import io.koraframework.application.graph.Lifecycle
import io.koraframework.common.annotation.Component
import io.koraframework.common.annotation.Root
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.model.CreateBucketRequest
import software.amazon.awssdk.services.s3.model.HeadBucketRequest
import software.amazon.awssdk.services.s3.model.NoSuchBucketException

@Root
@Component
class S3BucketInitializer(
    private val s3Client: S3Client,
    private val config: S3UploadsConfig
) : Lifecycle {

    override fun init() {
        val bucket = config.bucket()
        try {
            s3Client.headBucket(HeadBucketRequest.builder().bucket(bucket).build())
        } catch (e: NoSuchBucketException) {
            s3Client.createBucket(CreateBucketRequest.builder().bucket(bucket).build())
        }
    }

    override fun release() {}
}

@Root is not optional here

Kora builds only the part of the graph that something actually depends on. Nothing injects S3BucketInitializer, so without @Root it is pruned during graph construction, init() never runs, and the first upload fails against a bucket that was never created. @Root is what tells Kora to instantiate this component for its side effect alone. This is a general rule for lifecycle-only components, not an S3 quirk.

For the same pattern described from the other direction, see Bucket administration.

Metadata DTO

The S3 module already gives us low-level storage responses, but our HTTP API should expose a stable, guide-friendly DTO.

Create src/main/java/io/koraframework/guide/s3/s3/FileMetadata.java:

package io.koraframework.guide.s3.s3;

import io.koraframework.json.common.annotation.Json;

@Json
public record FileMetadata(String fileId, Long size, String contentType) {}

Create src/main/kotlin/io/koraframework/guide/s3/s3/FileMetadata.kt:

package io.koraframework.guide.s3.s3

import io.koraframework.json.common.annotation.Json

@Json
data class FileMetadata(
    val fileId: String,
    val size: Long?,
    val contentType: String?
)

We expose only the fields we actually use in the guide:

  • fileId for the public API contract
  • size and contentType for file inspection

Metadata Controller

Now we start wiring the declarative S3 client into the HTTP API.

In this first controller step we implement the upload operation that starts the file lifecycle in object storage.

This is a useful place to start because it shows the main responsibility split in the design:

  • the controller understands HTTP details such as FormMultipart
  • the S3 client understands storage keys and object operations

That split keeps the controller focused on request parsing and response shaping, while the declarative S3 client stays focused on object storage.

Update src/main/java/io/koraframework/guide/s3/controller/DataController.java:

package io.koraframework.guide.s3.controller;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
import io.koraframework.common.annotation.Component;
import io.koraframework.guide.s3.s3.FileMetadata;
import io.koraframework.guide.s3.s3.S3FileClient;
import io.koraframework.http.common.HttpMethod;
import io.koraframework.http.common.annotation.HttpRoute;
import io.koraframework.http.common.body.HttpBody;
import io.koraframework.http.common.form.FormMultipart;
import io.koraframework.http.common.header.HttpHeaders;
import io.koraframework.http.server.common.response.HttpServerResponse;
import io.koraframework.http.server.common.response.HttpServerResponseException;
import io.koraframework.http.server.common.annotation.HttpController;
import io.koraframework.json.common.annotation.Json;
import io.koraframework.s3.client.kora.exception.S3ClientNoSuchKeyException;

@Component
@HttpController
public final class DataController {

    private final S3FileClient s3FileClient;

    public DataController(S3FileClient s3FileClient) {
        this.s3FileClient = s3FileClient;
    }

    @HttpRoute(method = HttpMethod.POST, path = "/files/upload")
    @Json
    public FileMetadata uploadFile(FormMultipart multipart) {
        var filePart = multipart.parts().stream()
                .filter(part -> "file".equals(part.name()))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("No file part named 'file' provided"));

        if (filePart instanceof FormMultipart.FormPart.MultipartFile mf) {
            return this.upload(mf.contentType(), mf.content());
        }
        // a streamed part carries an HttpBodyOutput, which knows how to write itself out
        if (filePart instanceof FormMultipart.FormPart.MultipartFileStream mfs) {
            var buffer = new ByteArrayOutputStream();
            try {
                mfs.content().write(buffer);
            } catch (IOException e) {
                throw HttpServerResponseException.of(400, "Failed to read uploaded file");
            }
            return this.upload(mfs.content().contentType(), buffer.toByteArray());
        }

        throw new IllegalArgumentException("Part 'file' must be a multipart file");
    }

    private FileMetadata upload(String contentType, byte[] body) {
        String actualContentType = (contentType == null || contentType.isBlank()) ? "application/octet-stream" : contentType;
        String fileId = UUID.randomUUID().toString();
        this.s3FileClient.uploadFile(fileId, body);
        return new FileMetadata(fileId, (long) body.length, actualContentType);
    }

    private FileMetadata toMetadata(String key, Long size, String contentType) {
        String normalized = key.startsWith("files/") ? key.substring("files/".length()) : key;
        return new FileMetadata(normalized, size, contentType);
    }

    @Json
    public record DeleteFileResponse(String message) {}
}

Update src/main/kotlin/io/koraframework/guide/s3/controller/DataController.kt:

package io.koraframework.guide.s3.controller

import io.koraframework.common.annotation.Component
import io.koraframework.guide.s3.s3.FileMetadata
import io.koraframework.guide.s3.s3.S3FileClient
import io.koraframework.http.common.HttpMethod
import io.koraframework.http.common.annotation.HttpRoute
import io.koraframework.http.common.body.HttpBody
import io.koraframework.http.common.form.FormMultipart
import io.koraframework.http.common.header.HttpHeaders
import io.koraframework.http.server.common.annotation.HttpController
import io.koraframework.http.server.common.response.HttpServerResponse
import io.koraframework.http.server.common.response.HttpServerResponseException
import io.koraframework.json.common.annotation.Json
import io.koraframework.s3.client.kora.exception.S3ClientNoSuchKeyException
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.UUID

@Component
@HttpController
class DataController(
    private val s3FileClient: S3FileClient
) {

    @HttpRoute(method = HttpMethod.POST, path = "/files/upload")
    @Json
    fun uploadFile(multipart: FormMultipart): FileMetadata {
        val filePart = multipart.parts()
            .firstOrNull { it.name() == "file" }
            ?: throw IllegalArgumentException("No file part named 'file' provided")

        return when (filePart) {
            is FormMultipart.FormPart.MultipartFile -> upload(filePart.contentType(), filePart.content())

            // a streamed part carries an HttpBodyOutput, which knows how to write itself out
            is FormMultipart.FormPart.MultipartFileStream -> {
                val buffer = ByteArrayOutputStream()
                try {
                    filePart.content().write(buffer)
                } catch (e: IOException) {
                    throw HttpServerResponseException.of(400, "Failed to read uploaded file")
                }
                upload(filePart.content().contentType(), buffer.toByteArray())
            }

            else -> throw IllegalArgumentException("Part 'file' must be a multipart file")
        }
    }

    private fun upload(contentType: String?, body: ByteArray): FileMetadata {
        val actualContentType = if (contentType.isNullOrBlank()) "application/octet-stream" else contentType
        val fileId = UUID.randomUUID().toString()
        s3FileClient.uploadFile(fileId, body)
        return FileMetadata(fileId, body.size.toLong(), actualContentType)
    }

    private fun toMetadata(key: String, size: Long?, contentType: String?): FileMetadata {
        val normalized = key.removePrefix("files/")
        return FileMetadata(normalized, size, contentType)
    }

    @Json
    data class DeleteFileResponse(val message: String)
}

This controller keeps the example honest:

  • upload uses FormMultipart, because that is the most common HTTP file upload shape
  • storage keys stay internal to the controller and S3 client
  • the public API exposes only fileId, not raw bucket details or user-supplied paths

Multipart parts arrive in one of two shapes. MultipartFile already holds the bytes; MultipartFileStream holds an HttpBodyOutput, which is written into an OutputStream you supply. This guide buffers the streamed case into memory to keep the example short, which is the right call for small uploads and the wrong one for large ones — a real service would pipe that stream straight into the S3 client instead.

List, Download, and Delete

With upload in place, we can add the rest of the file lifecycle: reading what is stored, downloading the content, and deleting a file when it is no longer needed.

These endpoints solve the remaining read and cleanup parts of the API:

  • GET /files lets clients inspect what is already stored
  • GET /files/{fileId} turns an S3 object into a normal HTTP download response
  • DELETE /files/{fileId} removes an object when the application no longer needs it

This step is useful because it shows why the controller still matters even when storage is declarative. The S3 client returns storage-oriented objects, but the controller is responsible for:

  • mapping list results to the public DTO we want to expose
  • converting missing objects into a clean HTTP 404
  • building a normal downloadable HTTP response with content type and headers
  • coordinating delete requests through the same public fileId contract

Add the remaining endpoints to src/main/java/io/koraframework/guide/s3/controller/DataController.java:

    @HttpRoute(method = HttpMethod.GET, path = "/files")
    @Json
    public List<FileMetadata> listFiles() {
        return this.s3FileClient.listFiles().items().stream()
                .map(item -> this.toMetadata(item.key(), item.size(), null))
                .toList();
    }

    @HttpRoute(method = HttpMethod.GET, path = "/files/{fileId}")
    public HttpServerResponse downloadFile(String fileId) {
        try (var object = this.s3FileClient.downloadFile(fileId); var body = object.body().asInputStream()) {
            var bytes = body.readAllBytes();
            var contentType = object.headers().getFirst("Content-Type");
            return HttpServerResponse.of(
                    200,
                    HttpHeaders.of("Content-Disposition", "attachment; filename=\"" + fileId + "\""),
                    HttpBody.of(contentType == null ? "application/octet-stream" : contentType, bytes));
        } catch (S3ClientNoSuchKeyException e) {
            throw HttpServerResponseException.of(404, "File not found");
        } catch (IOException e) {
            throw HttpServerResponseException.of(500, "Failed to read file");
        }
    }

    @HttpRoute(method = HttpMethod.DELETE, path = "/files/{fileId}")
    @Json
    public DeleteFileResponse deleteFile(String fileId) {
        this.s3FileClient.deleteFile(fileId);
        return new DeleteFileResponse("File deleted successfully");
    }

Add the remaining endpoints to src/main/kotlin/io/koraframework/guide/s3/controller/DataController.kt:

    @HttpRoute(method = HttpMethod.GET, path = "/files")
    @Json
    fun listFiles(): List<FileMetadata> {
        return s3FileClient.listFiles().items()
            .map { toMetadata(it.key(), it.size(), null) }
    }

    @HttpRoute(method = HttpMethod.GET, path = "/files/{fileId}")
    fun downloadFile(fileId: String): HttpServerResponse {
        try {
            s3FileClient.downloadFile(fileId).use { obj ->
                obj.body().asInputStream().use { body ->
                    val bytes = body.readAllBytes()
                    val contentType = obj.headers().getFirst("Content-Type") ?: "application/octet-stream"
                    return HttpServerResponse.of(
                        200,
                        HttpHeaders.of("Content-Disposition", "attachment; filename=\"$fileId\""),
                        HttpBody.of(contentType, bytes)
                    )
                }
            }
        } catch (e: S3ClientNoSuchKeyException) {
            throw HttpServerResponseException.of(404, "File not found")
        } catch (e: IOException) {
            throw HttpServerResponseException.of(500, "Failed to read file")
        }
    }

    @HttpRoute(method = HttpMethod.DELETE, path = "/files/{fileId}")
    @Json
    fun deleteFile(fileId: String): DeleteFileResponse {
        s3FileClient.deleteFile(fileId)
        return DeleteFileResponse("File deleted successfully")
    }

The key idea here is that this second step completes the public file lifecycle. Listing translates low-level storage objects into your public FileMetadata contract, download translates an S3 object into a real HTTP file response, and delete gives the API a clean way to remove stored content by fileId.

Two things about the download path deserve attention. GetObjectResult is an HttpClientResponse, so it holds a live connection and must be closed — hence the try-with-resources in Java and use in Kotlin. And a missing key surfaces as S3ClientNoSuchKeyException, which is what the controller translates into a 404; a delete of a missing key does not throw at all, matching S3's own semantics.

Docker Compose

The application talks S3, but for local development we still need an S3-compatible server. MinIO is perfect for that.

Create docker-compose.yml in the application module directory:

services:
    minio:
        image: minio/minio:latest
        ports:
            - "9000:9000"
            - "9001:9001"
        environment:
            MINIO_ROOT_USER: minioadmin
            MINIO_ROOT_PASSWORD: minioadmin
        command: server /data --console-address ":9001"

Start it:

docker compose up -d

Then run the application with environment variables:

S3_URL=http://localhost:9000 \
S3_ACCESS_KEY=minioadmin \
S3_SECRET_KEY=minioadmin \
S3_BUCKET=uploads \
./gradlew run

On Windows PowerShell:

$env:S3_URL = 'http://localhost:9000'
$env:S3_ACCESS_KEY = 'minioadmin'
$env:S3_SECRET_KEY = 'minioadmin'
$env:S3_BUCKET = 'uploads'
./gradlew run

One S3_URL feeds both configuration sections, because both clients point at the same storage. There is no need to create the bucket by hand: S3BucketInitializer does it on startup.

Run Application

Compile first:

./gradlew clean classes

Then run the app with the same S3 environment variables shown above.

You can verify the API with examples like these:

curl -F "file=@./example.txt" http://localhost:8080/files/upload
curl http://localhost:8080/files
curl http://localhost:8080/files/<fileId>
curl -X DELETE http://localhost:8080/files/<fileId>

Testing

This guide's tests do not depend on a manually started MinIO instance. They use a MinIO Testcontainer and wire the connection values into the Kora app graph automatically through KoraAppTestConfigModifier, which sets S3_URL, S3_ACCESS_KEY, S3_SECRET_KEY, and S3_BUCKET as system properties from the running container.

build.gradle
dependencies {
    testImplementation("org.testcontainers:testcontainers-junit-jupiter:2.0.5")
    testImplementation("io.goodforgod:testcontainers-extensions-minio:0.15.0")
    testImplementation("io.koraframework:test-junit5")
}

Run them with the normal guide flow:

./gradlew test

That test setup verifies two things:

  • the declarative S3FileClient can upload, download, and delete objects
  • the extended DataController can expose the expected HTTP-level behavior on top of the S3 client

Best Practices

  • Use the declarative client for the request path and reach for the AWS SDK client only where the @S3 contract genuinely does not reach, such as bucket administration, copying, or presigned URLs.
  • Keep the declarative S3 client focused on one bucket and one clear key strategy.
  • Expose stable HTTP identifiers like fileId instead of leaking raw object keys into every API route.
  • Prefer a relative @S3.Bucket(".bucket") so the bucket name stays inside the client's own configuration section and moves with it.
  • Mark lifecycle-only components such as a bucket initializer with @Root, or the graph will prune them.
  • Pass a stream rather than a byte array for large uploads, and let the client's upload.singlePartUploadLimit decide when a multipart upload starts.
  • Always close a GetObjectResult: it holds a live HTTP response, not a detached buffer.
  • Use MinIO locally and in tests, but keep the configuration keys identical to production so only the endpoint and credentials change.

Summary

In this guide you extended the HTTP server application with S3-backed file storage.

You added:

  • both S3 artifacts and the HTTP client module they run on
  • separate s3client.aws and s3client.uploads configuration sections
  • a declarative S3FileClient bound to the uploads bucket
  • a @Root startup component that ensures the bucket exists through the AWS SDK client
  • file upload, list, download, and delete endpoints in DataController
  • MinIO-backed tests for the S3 flow

The main lesson is that Kora's declarative S3 client works especially well when the storage contract is simple and stable, while the AWS SDK client covers the administrative edges and the surrounding controller stays responsible for HTTP-specific concerns like multipart parsing and download responses.

Key Concepts

  • Two independent artifacts: io.koraframework.experimental:s3-client-kora for declarative clients and io.koraframework:s3-client-aws for the AWS SDK S3Client. Neither depends on the other, and using both together is a normal shape.
  • Declarative S3 clients map storage operations with @S3.Client, @S3.Bucket, @S3.Put, @S3.Get, @S3.List, and @S3.Delete.
  • Configuration is per client: s3client.aws is fixed and uses url, while a declarative client's path comes from its annotation and uses endpoint plus bucket.
  • Both clients need an HTTP client module in the graph; neither opens sockets of its own.
  • @Root keeps a lifecycle-only component alive when nothing in the graph depends on it.
  • MinIO is a server, not a client library: Kora 2.0 ships no MinIO-specific client artifact, and neither S3 artifact needs one.

Troubleshooting

./gradlew clean fails because files are locked:

Stop Gradle daemons and try again:

./gradlew --stop
./gradlew clean classes

Windows AccessDeniedException in Gradle cache:

This usually means a daemon or another Java process still holds files in the Gradle cache. Stop daemons first, then rerun the command.

./gradlew --stop
./gradlew test

Cannot resolve io.koraframework:s3-client-kora:

The declarative client lives under the experimental group. Use io.koraframework.experimental:s3-client-kora. The AWS wrapper, by contrast, is plain io.koraframework:s3-client-aws.

Cannot resolve s3-client-minio:

That artifact does not exist in Kora 2.0 and never needs to. Use either S3 artifact and point its endpoint at your MinIO server.

Startup fails with a missing configuration value:

Check which key the failing client wants. s3client.aws requires url; a declarative client requires endpoint. They are not interchangeable, and the bucket key belongs only to the declarative section.

Graph build fails because no HttpClient is available:

Add an HTTP client module such as OkHttpClientModule or JdkHttpClientModule. Both S3 artifacts need one.

The app cannot connect to MinIO:

Check that:

  • MinIO is running on http://localhost:9000
  • S3_URL, S3_ACCESS_KEY, and S3_SECRET_KEY are set
  • the bucket name in S3_BUCKET matches the guide configuration
  • addressStyle is left at its PATH default, which is what MinIO expects

Uploads fail because the bucket does not exist:

Confirm that S3BucketInitializer is annotated @Root. Without it the component is pruned from the graph and init() never runs.

GET /files/{fileId} returns 404:

This means the object key files/{fileId} does not exist in the configured bucket. Most often this happens because:

  • the object was deleted earlier
  • the app is pointing at a different bucket or MinIO instance
  • the upload request never completed successfully

Docker or Testcontainers cannot start MinIO:

Make sure Docker is running and available to your user. If container-based tests fail, inspect Docker logs and verify that ports 9000 and 9001 are free for manual runs.

What's Next?

  • Observability to add metrics, traces, logs, and probes around file operations.
  • HTTP Client to call file-related endpoints from another Kora service.
  • Resilient Patterns to protect storage calls against slow or unstable dependencies.
  • Database JDBC before black-box testing if you want the JDBC-backed end-to-end test path.

Help

If something does not line up: