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 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 - local development and tests against MinIO as an S3-compatible backend
What You'll Need¶
- JDK 17 or later
- Gradle 7+
- 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
The practical flow is:
- add the Kora S3 client module and its HTTP client dependency
- configure one declarative S3 client for an
uploadsbucket - map multipart upload requests to object writes
- map download routes to object reads
- 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 still uses Kora's AWS S3 module, so the development setup stays close to production-style object storage without requiring a real cloud account. Tests use containers to make storage behavior repeatable.
Dependencies¶
We are building on the existing HTTP server app, so we only add the S3 module and the HTTP client module required by the AWS S3 implementation.
http-client-ok is required because the AWS S3 module needs an HTTP client implementation under the hood.
Modules¶
Now we connect the S3 module to the existing HTTP server application.
Update src/main/java/ru/tinkoff/kora/guide/s3/Application.java:
package ru.tinkoff.kora.guide.s3;
import ru.tinkoff.kora.application.graph.KoraApplication;
import ru.tinkoff.kora.common.KoraApp;
import ru.tinkoff.kora.config.hocon.HoconConfigModule;
import ru.tinkoff.kora.http.client.ok.OkHttpClientModule;
import ru.tinkoff.kora.http.server.undertow.UndertowHttpServerModule;
import ru.tinkoff.kora.json.module.JsonModule;
import ru.tinkoff.kora.logging.logback.LogbackModule;
import ru.tinkoff.kora.s3.client.aws.AwsS3ClientModule;
@KoraApp
public interface Application extends
HoconConfigModule,
JsonModule,
LogbackModule,
OkHttpClientModule,
AwsS3ClientModule, // <----- Connected module
UndertowHttpServerModule {
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
Update src/main/kotlin/ru/tinkoff/kora/guide/s3/Application.kt:
package ru.tinkoff.kora.guide.s3
import ru.tinkoff.kora.application.graph.KoraApplication
import ru.tinkoff.kora.common.KoraApp
import ru.tinkoff.kora.config.hocon.HoconConfigModule
import ru.tinkoff.kora.http.client.ok.OkHttpClientModule
import ru.tinkoff.kora.http.server.undertow.UndertowHttpServerModule
import ru.tinkoff.kora.json.module.JsonModule
import ru.tinkoff.kora.logging.logback.LogbackModule
import ru.tinkoff.kora.s3.client.aws.AwsS3ClientModule
@KoraApp
interface Application :
HoconConfigModule,
JsonModule,
LogbackModule,
OkHttpClientModule,
AwsS3ClientModule, // <----- Connected module
UndertowHttpServerModule
fun main() {
KoraApplication.run(ApplicationGraph::graph)
}
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 only add the new s3client section.
For the full configuration reference, see S3 Client.
s3client {
url = ${S3_URL} //(1)!
accessKey = ${S3_ACCESS_KEY} //(2)!
secretKey = ${S3_SECRET_KEY} //(3)!
region = "us-east-1" //(4)!
region = ${?S3_REGION} //(5)!
uploads {
bucket = "uploads" //(6)!
bucket = ${?S3_BUCKET} //(7)!
}
}
- Required S3 endpoint URL from
S3_URL. - Required S3 access key from
S3_ACCESS_KEY. - Required S3 secret key from
S3_SECRET_KEY. - Default S3 region used for local MinIO.
- Optional region override from
S3_REGION. - Default bucket name for the
uploadsdeclarative client. - Optional bucket override from
S3_BUCKET.
s3client:
url: ${S3_URL} #(1)!
accessKey: ${S3_ACCESS_KEY} #(2)!
secretKey: ${S3_SECRET_KEY} #(3)!
region: ${?S3_REGION:"us-east-1"} #(4)!
uploads:
bucket: ${?S3_BUCKET:"uploads"} #(5)!
- Required S3 endpoint URL from
S3_URL. - Required S3 access key from
S3_ACCESS_KEY. - Required S3 secret key from
S3_SECRET_KEY. - S3 region with a local default and optional
S3_REGIONoverride. - Upload bucket with a local default and optional
S3_BUCKEToverride.
This configuration does two things:
- configures the shared AWS S3 client connection
- configures one named declarative client at
s3client.uploads
That uploads subsection is important because we'll bind it directly to @S3.Client("s3client.uploads") in the next step.
MinIO is still useful here even though we use the AWS client implementation. MinIO speaks the S3 API, so it gives us a cheap local environment while keeping the application code aligned with AWS-style clients.
Declarative S3 Client¶
Kora's S3 module supports declarative clients in the same spirit as its HTTP client support. That is the main concept this guide is teaching.
Create src/main/java/ru/tinkoff/kora/guide/s3/s3/S3FileClient.java:
package ru.tinkoff.kora.guide.s3.s3;
import ru.tinkoff.kora.s3.client.annotation.S3;
import ru.tinkoff.kora.s3.client.model.S3Body;
import ru.tinkoff.kora.s3.client.model.S3Object;
import ru.tinkoff.kora.s3.client.model.S3ObjectList;
import ru.tinkoff.kora.s3.client.model.S3ObjectUpload;
@S3.Client("s3client.uploads")
public interface S3FileClient {
@S3.Put("files/{fileId}")
S3ObjectUpload uploadFile(String fileId, S3Body body);
@S3.Get("files/{fileId}")
S3Object downloadFile(String fileId);
@S3.List("files/")
S3ObjectList listFiles();
@S3.Delete("files/{fileId}")
void deleteFile(String fileId);
}
Create src/main/kotlin/ru/tinkoff/kora/guide/s3/s3/S3FileClient.kt:
package ru.tinkoff.kora.guide.s3.s3
import ru.tinkoff.kora.s3.client.annotation.S3
import ru.tinkoff.kora.s3.client.model.S3Body
import ru.tinkoff.kora.s3.client.model.S3Object
import ru.tinkoff.kora.s3.client.model.S3ObjectList
import ru.tinkoff.kora.s3.client.model.S3ObjectUpload
@S3.Client("s3client.uploads")
interface S3FileClient {
@S3.Put("files/{fileId}")
fun uploadFile(fileId: String, body: S3Body): S3ObjectUpload
@S3.Get("files/{fileId}")
fun downloadFile(fileId: String): S3Object
@S3.List("files/")
fun listFiles(): S3ObjectList
@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.Put("files/{fileId}")builds the final object key from the method argument@S3.List("files/")limits listing to one prefix, which keeps the example deterministicS3Bodyis how we stream or materialize the uploaded file into the S3 client
For more annotation patterns such as templates, metadata-only responses, and list variations, see the S3 Client documentation.
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/ru/tinkoff/kora/guide/s3/s3/FileMetadata.java:
We expose only the fields we actually use in the guide:
fileIdfor the public API contractsizeandcontentTypefor 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/ru/tinkoff/kora/guide/s3/controller/DataController.java:
package ru.tinkoff.kora.guide.s3.controller;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import ru.tinkoff.kora.common.Component;
import ru.tinkoff.kora.common.util.ByteBufferPublisherInputStream;
import ru.tinkoff.kora.guide.s3.s3.FileMetadata;
import ru.tinkoff.kora.guide.s3.s3.S3FileClient;
import ru.tinkoff.kora.http.common.HttpMethod;
import ru.tinkoff.kora.http.common.annotation.HttpRoute;
import ru.tinkoff.kora.http.common.body.HttpBody;
import ru.tinkoff.kora.http.common.form.FormMultipart;
import ru.tinkoff.kora.http.common.header.HttpHeaders;
import ru.tinkoff.kora.http.server.common.HttpServerResponse;
import ru.tinkoff.kora.http.server.common.HttpServerResponseException;
import ru.tinkoff.kora.http.server.common.annotation.HttpController;
import ru.tinkoff.kora.json.common.annotation.Json;
import ru.tinkoff.kora.s3.client.S3NotFoundException;
import ru.tinkoff.kora.s3.client.model.S3Body;
@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.uploadStream(mf.fileName(), mf.contentType(), new ByteArrayInputStream(mf.content()));
}
if (filePart instanceof FormMultipart.FormPart.MultipartFileStream mfs) {
return this.uploadStream(mfs.fileName(), mfs.contentType(), new ByteBufferPublisherInputStream(mfs.content()));
}
throw new IllegalArgumentException("Part 'file' must be a multipart file");
}
private FileMetadata uploadStream(String fileName, String contentType, InputStream inputStream) {
String actualContentType = (contentType == null || contentType.isBlank()) ? "application/octet-stream" : contentType;
String fileId = java.util.UUID.randomUUID().toString();
S3Body body = S3Body.ofInputStreamReadAll(inputStream, actualContentType);
this.s3FileClient.uploadFile(fileId, body);
return new FileMetadata(fileId, body.size(), 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/ru/tinkoff/kora/guide/s3/controller/DataController.kt:
package ru.tinkoff.kora.guide.s3.controller
import java.io.ByteArrayInputStream
import java.io.InputStream
import ru.tinkoff.kora.common.Component
import ru.tinkoff.kora.common.util.ByteBufferPublisherInputStream
import ru.tinkoff.kora.guide.s3.s3.FileMetadata
import ru.tinkoff.kora.guide.s3.s3.S3FileClient
import ru.tinkoff.kora.http.common.HttpMethod
import ru.tinkoff.kora.http.common.annotation.HttpRoute
import ru.tinkoff.kora.http.common.body.HttpBody
import ru.tinkoff.kora.http.common.form.FormMultipart
import ru.tinkoff.kora.http.common.header.HttpHeaders
import ru.tinkoff.kora.http.server.common.HttpServerResponse
import ru.tinkoff.kora.http.server.common.HttpServerResponseException
import ru.tinkoff.kora.http.server.common.annotation.HttpController
import ru.tinkoff.kora.json.common.annotation.Json
import ru.tinkoff.kora.s3.client.S3NotFoundException
import ru.tinkoff.kora.s3.client.model.S3Body
@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 -> {
uploadStream(filePart.fileName(), filePart.contentType(), ByteArrayInputStream(filePart.content()))
}
is FormMultipart.FormPart.MultipartFileStream -> {
uploadStream(filePart.fileName(), filePart.contentType(), ByteBufferPublisherInputStream(filePart.content()))
}
else -> throw IllegalArgumentException("Part 'file' must be a multipart file")
}
}
private fun uploadStream(fileName: String?, contentType: String?, inputStream: InputStream): FileMetadata {
val actualContentType = if (contentType.isNullOrBlank()) "application/octet-stream" else contentType
val fileId = java.util.UUID.randomUUID().toString()
val body = S3Body.ofInputStreamReadAll(inputStream, actualContentType)
s3FileClient.uploadFile(fileId, body)
return FileMetadata(fileId, body.size(), actualContentType)
}
private fun toMetadata(key: String, size: Long?, contentType: String?): FileMetadata {
val normalized = if (key.startsWith("files/")) key.removePrefix("files/") else key
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
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 /fileslets clients inspect what is already storedGET /files/{fileId}turns an S3 object into a normal HTTP download responseDELETE /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
fileIdcontract
Add the remaining endpoints to src/main/java/ru/tinkoff/kora/guide/s3/controller/DataController.java:
@HttpRoute(method = HttpMethod.GET, path = "/files")
@Json
public java.util.List<FileMetadata> listFiles() {
return this.s3FileClient.listFiles().objects().stream()
.map(object -> this.toMetadata(object.key(), object.size(), object.body().type()))
.toList();
}
@HttpRoute(method = HttpMethod.GET, path = "/files/{fileId}")
public HttpServerResponse downloadFile(String fileId) {
try {
var object = this.s3FileClient.downloadFile(fileId);
var bytes = object.body().asBytes();
var contentType = object.body().type() == null ? "application/octet-stream" : object.body().type();
return HttpServerResponse.of(
200,
HttpHeaders.of("Content-Disposition", "attachment; filename=\"" + fileId + "\""),
HttpBody.of(contentType, bytes));
} catch (S3NotFoundException e) {
throw HttpServerResponseException.of(404, "File not found");
}
}
@HttpRoute(method = HttpMethod.DELETE, path = "/files/{fileId}")
@Json
public DeleteFileResponse deleteFile(String fileId) {
this.s3FileClient.deleteFile(fileId);
return new DeleteFileResponse("File deleted");
}
Add the remaining endpoints to src/main/kotlin/ru/tinkoff/kora/guide/s3/controller/DataController.kt:
@HttpRoute(method = HttpMethod.GET, path = "/files")
@Json
fun listFiles(): List<FileMetadata> {
return s3FileClient.listFiles().objects().map { object ->
toMetadata(object.key(), object.size(), object.body().type())
}
}
@HttpRoute(method = HttpMethod.GET, path = "/files/{fileId}")
fun downloadFile(fileId: String): HttpServerResponse {
return try {
val objectResponse = s3FileClient.downloadFile(fileId)
val bytes = objectResponse.body().asBytes()
val contentType = objectResponse.body().type() ?: "application/octet-stream"
HttpServerResponse.of(
200,
HttpHeaders.of("Content-Disposition", "attachment; filename=\"$fileId\""),
HttpBody.of(contentType, bytes)
)
} catch (_: S3NotFoundException) {
throw HttpServerResponseException.of(404, "File not found")
}
}
@HttpRoute(method = HttpMethod.DELETE, path = "/files/{fileId}")
@Json
fun deleteFile(fileId: String): DeleteFileResponse {
s3FileClient.deleteFile(fileId)
return DeleteFileResponse("File deleted")
}
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.
Docker Compose¶
The application code uses the AWS S3 client, 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:
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
Run Application¶
Compile first:
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.
Run them with the normal guide flow:
That test setup verifies two things:
- the declarative
S3FileClientcan upload, download, and delete objects - the extended
DataControllercan expose the expected HTTP-level behavior on top of the S3 client
Best Practices¶
- Keep the declarative S3 client focused on one bucket and one clear key strategy.
- Expose stable HTTP identifiers like
fileIdinstead of leaking raw object keys into every API route. - Use
S3Bodyconstructors that match your data shape. For very large streams, prefer streaming variants rather than buffering whole files in memory. - Use MinIO locally, but keep the application code aligned with the AWS client module if production will use AWS-style infrastructure.
Summary¶
In this guide you extended the HTTP server application with S3-backed file storage.
You added:
- the AWS S3 client module and its HTTP client dependency
- a declarative
S3FileClient - a startup component that ensures the bucket exists
- 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 surrounding controller stays responsible for HTTP-specific concerns like multipart parsing and download responses.
Key Concepts¶
- S3-compatible storage lets you develop locally with MinIO while still targeting AWS-style clients and infrastructure.
- Declarative S3 clients map storage operations with
@S3.Client,@S3.Put,@S3.Get,@S3.List, and@S3.Delete. - HTTP and storage concerns should stay separated: controllers handle HTTP contracts, and declarative clients handle object storage contracts.
- MinIO-backed tests provide realistic verification without requiring a manually managed S3 environment.
Troubleshooting¶
./gradlew clean fails because files are locked:
Stop Gradle daemons and try again:
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.
The app cannot connect to MinIO:
Check that:
- MinIO is running on
http://localhost:9000 S3_URL,S3_ACCESS_KEY, andS3_SECRET_KEYare set- the bucket name in
S3_BUCKETmatches the guide configuration
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:
- compare with Kora Java S3 App and Kora Kotlin S3 App
- revisit HTTP Server Advanced for multipart request handling
- check the S3 Client documentation
- check the HTTP Server documentation
- check the Configuration documentation