Advanced HTTP Server Guide¶
This guide introduces advanced HTTP server capabilities in Kora. It covers how request context, form bodies, multipart uploads, controller interceptors, global error handling, and simple API-key authorization fit around the same controller-service structure used by basic APIs. You will also see how these transport concerns stay explicit at the HTTP boundary without forcing storage or application logic to know about low-level request handling.
If you want to check your progress along the way, use the finished working example: Kora Java HTTP Server Advanced App.
If you want to check your progress along the way, use the finished working example: Kora Kotlin HTTP Server Advanced App.
What You'll Build¶
You will extend the server with:
- a typed
RequestContextMapper - a
DataControllerfor forms, multipart uploads, and helper routes for the advanced client guide - a controller-level
LoggingInterceptor - a shared
ErrorResponse - a global
ExceptionHandler - a simple
Authorization: ApiKey ...check forDataController
What You'll Need¶
- JDK 25 or later
- Gradle 9+
- A text editor or IDE
- Completed HTTP Server Guide
Prerequisites¶
Required: Complete HTTP Server Guide
This guide assumes you have completed HTTP Server Guide and have the working CRUD application with Application, UserController, UserService, UserRepository, and InMemoryUserRepository.
If you haven't completed the HTTP server guide yet, do that first, because this guide extends the same API with advanced request mapping, interceptors, error handling, and authorization.
Overview¶
Basic JSON CRUD routes cover the most common HTTP use case, but HTTP has a wider surface area than JSON bodies and path variables. Real APIs often need richer request mapping, reusable behavior around routes, consistent error responses, and lightweight security checks at the transport boundary.
The advanced server guide keeps the same application model and expands only the HTTP edge. That mirrors production code: the service and repository should not need to know whether a request came from JSON, a form, a multipart upload, or a route protected by an interceptor.
Request Forms Beyond JSON¶
Not every HTTP request is a JSON document. Some endpoints receive form fields, uploaded files, raw bodies, headers, or request metadata. Kora lets controller methods declare these inputs as typed parameters, so the method signature still describes the transport contract.
This guide expands request handling with:
- request context for metadata that belongs to the current HTTP request
- form fields for classic
application/x-www-form-urlencodedflows - multipart parts for file upload style endpoints
- helper routes that demonstrate custom mapping and response control
Cross-Cutting HTTP Behavior¶
Some behavior should apply around routes instead of inside every method body. Interceptors are the HTTP-server tool for that. They can observe or modify request handling before and after a controller method runs. That makes them suitable for logging, lightweight authorization, request enrichment, or other transport-level policies.
Because request handling is synchronous, an interceptor is an ordinary method call around the rest of the chain: it calls chain.process(request), gets an HttpServerResponse back, and can wrap the
call in try/catch/finally. There is no callback, no CompletionStage, and no suspend function in the contract.
The important boundary is that interceptors should stay focused on HTTP concerns. They should not become a hidden service layer.
Error and Authorization Boundaries¶
As APIs grow, inconsistent errors become painful for clients. A shared exception handler gives failures a predictable response shape. Simple API-key authorization shows another common transport boundary: the controller area can be protected before business logic runs, while services and repositories stay unaware of headers and auth metadata.
By the end of this guide, the HTTP server layer should feel like more than route annotations: it is the place where request mapping, response shaping, interception, error handling, and simple authorization are coordinated.
The practical flow is:
- add a request context mapper for one route
- add form and multipart handling in a separate controller
- introduce controller interceptors
- centralize error responses with an exception handler
- protect one controller area with a simple API key
Custom Mapper¶
For the full rules for custom route parameters and HttpServerRequestMapper<T>, see HTTP Server custom parameters.
Sometimes a route needs more than a JSON body or a path variable. It may also need request metadata such as:
- a request ID from headers
- a user agent
- a session ID from cookies
You could pass all of those values as separate method parameters, but once they conceptually belong together, a typed object is easier to read and easier to evolve later.
That is what HttpServerRequestMapper<T> is for. It lets you derive one typed parameter from the raw HTTP request. The interface lives in io.koraframework.http.server.common.request and has a
single synchronous method, T apply(HttpServerRequest request).
A mapper referenced from @Mapping is not created by the generated controller module — it is requested from the dependency graph. That is why the mapper class carries @Component even though it
has no constructor dependencies of its own.
Create the RequestContextMapper:
Add these nested types inside UserController.java, together with the imports for org.jspecify.annotations.Nullable, io.koraframework.http.common.cookie.Cookie,
io.koraframework.http.server.common.request.HttpServerRequest and io.koraframework.http.server.common.request.HttpServerRequestMapper:
public record RequestContext(@Nullable String requestId, @Nullable String userAgent, @Nullable String sessionId) {}
@Component
public static final class RequestContextMapper implements HttpServerRequestMapper<RequestContext> {
@Override
public RequestContext apply(HttpServerRequest request) {
String sessionId = request.cookies().stream()
.filter(cookie -> "sessionId".equals(cookie.name()))
.map(Cookie::value)
.findFirst()
.orElse(null);
return new RequestContext(
request.headers().getFirst("X-Request-ID"),
request.headers().getFirst("User-Agent"),
sessionId);
}
}
Add the same idea in UserController.kt, together with the imports for io.koraframework.http.server.common.request.HttpServerRequest and
io.koraframework.http.server.common.request.HttpServerRequestMapper:
data class RequestContext(
val requestId: String?,
val userAgent: String?,
val sessionId: String?
)
@Component
class RequestContextMapper : HttpServerRequestMapper<RequestContext> {
override fun apply(request: HttpServerRequest): RequestContext {
val sessionId = request.cookies()
.firstOrNull { it.name() == "sessionId" }
?.value()
return RequestContext(
request.headers().getFirst("X-Request-ID"),
request.headers().getFirst("User-Agent"),
sessionId
)
}
}
Use it on createUser():
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
public HttpResponseEntity<UserResponse> createUser(
@Json UserRequest request,
@Mapping(RequestContextMapper.class) RequestContext context) {
System.out.printf(
"Creating user with request ID: %s, user agent: %s, session ID: %s%n",
context.requestId(), context.userAgent(), context.sessionId());
UserResponse user = userService.createUser(request);
return HttpResponseEntity.of(201, HttpHeaders.of(), user);
}
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
fun createUser(
@Json request: UserRequest,
@Mapping(RequestContextMapper::class) context: RequestContext
): HttpResponseEntity<UserResponse> {
println(
"Creating user with request ID: ${context.requestId}, " +
"user agent: ${context.userAgent}, session ID: ${context.sessionId}"
)
val user = userService.createUser(request)
return HttpResponseEntity.of(201, HttpHeaders.of(), user)
}
Why this abstraction is useful:
HttpServerRequestMapper<T>lets you create any typed object from the request@Mapping(...)fromio.koraframework.common.annotationtells Kora to use that mapper for one specific parameter- the route signature stays compact even when the route needs several request-derived values
This is often a better fit than endlessly growing controller method signatures.
An exception thrown by a mapper is turned into a 400 response unless the exception is itself an HttpServerResponse, so a mapper is also a convenient place to reject a malformed request with an
exact status code.
New Controller¶
The full request-body model for JSON, forms, and multipart is described in HTTP Server request body.
The next advanced topic is request bodies that are not JSON.
So far the base guide used only JSON DTOs. Real HTTP APIs also often need:
application/x-www-form-urlencodedmultipart/form-data
What these formats are:
application/x-www-form-urlencodedis the classic browser form format. A very typical example is a standard account-creation form on a website where the browser submits a small set of text fields.multipart/form-datais the format used when the request is split into named parts, especially when files or binary content are involved.
You can think about them like this:
- use form-url-encoded when the body is basically a small set of text fields
- use multipart when the body is made of named parts and some of those parts may be files
Even in JSON-first systems, these formats still appear often:
- browser-based admin tools
- legacy integrations
- upload endpoints
- webhook providers
Both FormUrlEncoded and FormMultipart live in io.koraframework.http.common.form, and Kora already provides the request mappers for them, so a controller method only has to declare the parameter.
DataController helps because we keep these routes out of UserController on purpose:
UserControllerstays focused on user CRUDDataControllerbecomes a transport playground for alternate HTTP body formats
That keeps the business-oriented controller easier to read.
Create DataController:
package io.koraframework.guide.httpserver.advanced.controller;
import java.util.List;
import java.util.stream.Collectors;
import io.koraframework.common.annotation.Component;
import io.koraframework.http.common.HttpMethod;
import io.koraframework.http.common.annotation.HttpRoute;
import io.koraframework.http.common.annotation.Path;
import io.koraframework.http.common.form.FormMultipart;
import io.koraframework.http.common.form.FormUrlEncoded;
import io.koraframework.http.server.common.annotation.HttpController;
import io.koraframework.http.server.common.response.HttpServerResponseException;
import io.koraframework.json.common.annotation.Json;
@Component
@HttpController
public final class DataController {
@HttpRoute(method = HttpMethod.POST, path = "/data/form")
public String processForm(FormUrlEncoded formBody) {
var namePart = formBody.get("name");
var name = namePart == null || namePart.values().isEmpty() ? "World" : namePart.values().get(0);
return "Hello World, " + name;
}
@HttpRoute(method = HttpMethod.POST, path = "/data/upload")
@Json
public UploadResponse processUpload(FormMultipart multipart) {
List<String> fileNames = multipart.parts().stream()
.map(FormMultipart.FormPart::name)
.sorted()
.collect(Collectors.toList());
return new UploadResponse(fileNames.size(), fileNames);
}
@HttpRoute(method = HttpMethod.POST, path = "/data/mapping-request")
public String processMappedRequest(String body) {
return "Received mapped body: " + body;
}
@HttpRoute(method = HttpMethod.GET, path = "/data/mapping-by-code/{code}")
@Json
public Payload mappingByCode(@Path int code) {
if (code == 200) {
return new Payload("Hello from response mapper");
}
throw HttpServerResponseException.of(code, "Request failed with code " + code);
}
@Json
public record Payload(String message) {}
@Json
public record UploadResponse(int fileCount, List<String> fileNames) {}
}
package io.koraframework.guide.httpserver.advanced.controller
import io.koraframework.common.annotation.Component
import io.koraframework.http.common.HttpMethod
import io.koraframework.http.common.annotation.HttpRoute
import io.koraframework.http.common.annotation.Path
import io.koraframework.http.common.form.FormMultipart
import io.koraframework.http.common.form.FormUrlEncoded
import io.koraframework.http.server.common.annotation.HttpController
import io.koraframework.http.server.common.response.HttpServerResponseException
import io.koraframework.json.common.annotation.Json
@Component
@HttpController
class DataController {
@HttpRoute(method = HttpMethod.POST, path = "/data/form")
fun processForm(formBody: FormUrlEncoded): String {
val name = formBody.get("name")?.values()?.firstOrNull() ?: "World"
return "Hello World, $name"
}
@HttpRoute(method = HttpMethod.POST, path = "/data/upload")
@Json
fun processUpload(multipart: FormMultipart): UploadResponse {
val fileNames = multipart.parts().map { it.name() }.sorted()
return UploadResponse(fileNames.size, fileNames)
}
@HttpRoute(method = HttpMethod.POST, path = "/data/mapping-request")
fun processMappedRequest(body: String): String {
return "Received mapped body: $body"
}
@HttpRoute(method = HttpMethod.GET, path = "/data/mapping-by-code/{code}")
@Json
fun mappingByCode(@Path code: Int): Payload {
if (code == 200) {
return Payload("Hello from response mapper")
}
throw HttpServerResponseException.of(code, "Request failed with code $code")
}
}
@Json
data class Payload(val message: String)
@Json
data class UploadResponse(val fileCount: Int, val fileNames: List<String>)
FormUrlEncoded.get(name) returns a nullable FormPart with a list of values, and FormMultipart.parts() returns a sealed FormPart hierarchy where a part is either data, a file, or a file
stream — that is why the upload route only reads name() here.
The helper routes at the bottom are intentionally tiny. They exist so the next guide, HTTP Client Advanced Guide, can demonstrate:
- custom request mapping on
POST /data/mapping-request - response-code-specific decoding on
GET /data/mapping-by-code/{code}
The success branch returns a tiny Payload(message). The error branch throws HttpServerResponseException, and the global ExceptionHandler turns that into the shared ErrorResponse(message) JSON
contract for non-200 responses.
Logging Interceptor¶
For more on local and global HTTP server interceptors, see HTTP Server interceptors.
The next topic is interceptors.
An interceptor is useful when you want reusable behavior around request handling, for example:
- logging
- timing
- metrics
- security checks
- custom cross-cutting transport logic
The important design question is scope.
Sometimes you want behavior for the whole server. Sometimes you want it only around one controller or one route group. We start here with the narrower and safer case: a controller-level interceptor.
Create LoggingInterceptor:
package io.koraframework.guide.httpserver.advanced.controller;
import io.koraframework.common.annotation.Component;
import io.koraframework.http.server.common.interceptor.HttpServerInterceptor;
import io.koraframework.http.server.common.request.HttpServerRequest;
import io.koraframework.http.server.common.response.HttpServerResponse;
@Component
public final class LoggingInterceptor implements HttpServerInterceptor {
@Override
public HttpServerResponse intercept(HttpServerRequest request, InterceptChain chain) throws Exception {
long started = System.nanoTime();
int statusCode = 500;
try {
var response = chain.process(request);
statusCode = response.code();
return response;
} finally {
long durationMs = (System.nanoTime() - started) / 1_000_000;
System.out.printf("Request: %s %s -> %d (%d ms)%n", request.method(), request.path(), statusCode, durationMs);
}
}
}
package io.koraframework.guide.httpserver.advanced.controller
import io.koraframework.common.annotation.Component
import io.koraframework.http.server.common.interceptor.HttpServerInterceptor
import io.koraframework.http.server.common.request.HttpServerRequest
import io.koraframework.http.server.common.response.HttpServerResponse
@Component
class LoggingInterceptor : HttpServerInterceptor {
override fun intercept(request: HttpServerRequest, chain: HttpServerInterceptor.InterceptChain): HttpServerResponse {
val started = System.nanoTime()
var statusCode = 500
try {
val response = chain.process(request)
statusCode = response.code()
return response
} finally {
val durationMs = (System.nanoTime() - started) / 1_000_000
println("Request: ${request.method()} ${request.path()} -> $statusCode ($durationMs ms)")
}
}
}
The interceptor contract is one synchronous method:
intercept(request, chain)receives the incoming request and the rest of the chainchain.process(request)runs the remaining interceptors and the controller method and returns the response- returning without calling
chain.process(...)short-circuits the route - an exception raised deeper in the chain simply propagates, so
try/finallyis enough to always log a line
The initial statusCode = 500 is what gets logged when the chain throws: chain.process(request) never returns a value in that case, and the finally block still runs.
Apply it only to UserController:
@InterceptWith comes from io.koraframework.http.common.annotation. It is repeatable and can be placed on the controller class or on a single route method; interceptors declared on the class run
before those declared on the method. Like @Mapping, it references a class that Kora resolves from the graph, so LoggingInterceptor must be a @Component.
This is a good example of why controller-scoped interceptors are useful:
- they keep the behavior reusable
- but they do not affect unrelated controllers
- and they are often easier to reason about than immediately making behavior global
Error Interceptor¶
The complete exception and error mapping options are described in HTTP Server error handling.
Now we move from controller-local behavior to server-wide behavior.
Error handling is a classic case where teams often want stronger control:
- the same JSON shape for all errors
- one place to translate exceptions into HTTP responses
- less repeated error-formatting logic in controllers
That is why a shared ErrorResponse and a global ExceptionHandler are common patterns.
Create ErrorResponse:
Create a small exception for a deliberately restricted form name:
package io.koraframework.guide.httpserver.advanced.controller;
public final class RestrictedFormNameException extends RuntimeException {
public RestrictedFormNameException(String name) {
super("Form name '" + name + "' is restricted");
}
}
Now update the form route so the new exception has a concrete source:
@HttpRoute(method = HttpMethod.POST, path = "/data/form")
public String processForm(FormUrlEncoded formBody) {
var namePart = formBody.get("name");
var name = namePart == null || namePart.values().isEmpty() ? "World" : namePart.values().get(0);
if ("admin".equalsIgnoreCase(name)) {
throw new RestrictedFormNameException(name);
}
return "Hello World, " + name;
}
Create the global ExceptionHandler:
Kora collects global interceptors by the @Tag(HttpServer.class) tag: HttpServerModule declares the router dependency as @Tag(HttpServer.class) All<HttpServerInterceptor> interceptors. That tag,
and only that tag, is what makes this interceptor global rather than controller-local. HttpServer is imported from io.koraframework.http.server.common.
Because handling is synchronous, the handler is a plain try/catch around chain.process(request). The interceptor depends on JsonWriter<ErrorResponse>, so it can always serialize the same typed
error body instead of building ad-hoc strings by hand. That constructor dependency is also the reason this class must be a graph component.
package io.koraframework.guide.httpserver.advanced.controller;
import io.koraframework.common.annotation.Component;
import io.koraframework.common.annotation.Tag;
import io.koraframework.guide.httpserver.advanced.dto.ErrorResponse;
import io.koraframework.http.common.body.HttpBody;
import io.koraframework.http.server.common.HttpServer;
import io.koraframework.http.server.common.interceptor.HttpServerInterceptor;
import io.koraframework.http.server.common.request.HttpServerRequest;
import io.koraframework.http.server.common.response.HttpServerResponse;
import io.koraframework.http.server.common.response.HttpServerResponseException;
import io.koraframework.json.common.JsonWriter;
@Tag(HttpServer.class)
@Component
public final class ExceptionHandler implements HttpServerInterceptor {
private final JsonWriter<ErrorResponse> errorJsonWriter;
public ExceptionHandler(JsonWriter<ErrorResponse> errorJsonWriter) {
this.errorJsonWriter = errorJsonWriter;
}
@Override
public HttpServerResponse intercept(HttpServerRequest request, InterceptChain chain) {
try {
return chain.process(request);
} catch (RestrictedFormNameException e) {
return jsonResponse(400, e.getMessage());
} catch (HttpServerResponseException e) {
return jsonResponse(e.code(), e.getMessage());
} catch (IllegalArgumentException e) {
return jsonResponse(400, "Invalid request parameters");
} catch (SecurityException e) {
return jsonResponse(403, e.getMessage() != null ? e.getMessage() : "Access denied");
} catch (Exception e) {
return jsonResponse(500, "An unexpected error occurred");
}
}
private HttpServerResponse jsonResponse(int statusCode, String message) {
return HttpServerResponse.of(statusCode, HttpBody.json(this.errorJsonWriter.toByteArray(new ErrorResponse(message))));
}
}
package io.koraframework.guide.httpserver.advanced.controller
import io.koraframework.common.annotation.Component
import io.koraframework.common.annotation.Tag
import io.koraframework.guide.httpserver.advanced.dto.ErrorResponse
import io.koraframework.http.common.body.HttpBody
import io.koraframework.http.server.common.HttpServer
import io.koraframework.http.server.common.interceptor.HttpServerInterceptor
import io.koraframework.http.server.common.request.HttpServerRequest
import io.koraframework.http.server.common.response.HttpServerResponse
import io.koraframework.http.server.common.response.HttpServerResponseException
import io.koraframework.json.common.JsonWriter
@Tag(HttpServer::class)
@Component
class ExceptionHandler(
private val errorJsonWriter: JsonWriter<ErrorResponse>
) : HttpServerInterceptor {
override fun intercept(request: HttpServerRequest, chain: HttpServerInterceptor.InterceptChain): HttpServerResponse {
return try {
chain.process(request)
} catch (e: RestrictedFormNameException) {
jsonResponse(400, e.message ?: "Restricted form name")
} catch (e: HttpServerResponseException) {
jsonResponse(e.code(), e.message ?: "HTTP error")
} catch (e: IllegalArgumentException) {
jsonResponse(400, "Invalid request parameters")
} catch (e: SecurityException) {
jsonResponse(403, e.message ?: "Access denied")
} catch (e: Exception) {
jsonResponse(500, "An unexpected error occurred")
}
}
private fun jsonResponse(statusCode: Int, message: String): HttpServerResponse {
return HttpServerResponse.of(
statusCode,
HttpBody.json(errorJsonWriter.toByteArray(ErrorResponse(message)))
)
}
}
JsonWriter.toByteArray(...) declares no checked exception, so the handler needs no extra IOException branch around serialization.
The tag is what makes it global
Only @Tag(HttpServer.class) / @Tag(HttpServer::class) registers a global interceptor for the public server. Any other tag still compiles, and the interceptor is simply never invoked, so the
error contract silently disappears. To intercept every request of the system server instead, use the @SystemApi tag. When several global interceptors exist, they are applied in a
deterministic order sorted by the interceptor class simple name.
Keep the regular user lookup local to UserController:
This is a useful separation:
- the form route throws a custom application error only for the new advanced behavior
- regular HTTP status failures can still use
HttpServerResponseException - one interceptor translates both forms into the same response shape
- the whole API now returns the same
ErrorResponseshape
The global interceptor also wraps the 404 and 405 responses produced by the router itself, and parameter parsing failures, which Kora answers with 400 before the controller method runs. That is
what makes it a real error contract instead of a per-controller convenience.
API Key Authorization¶
This section uses an interceptor as the transport boundary; the general interceptor rules are covered in the HTTP Server documentation.
The last step introduces a small security mechanism.
We do not protect the whole application. We protect only DataController, because it is a nice isolated place to demonstrate the pattern without making the main CRUD flow harder to follow.
The idea is intentionally simple:
- the expected API key lives in configuration
- the value can come from
HTTP_ADVANCED_API_KEY - an interceptor reads the
Authorizationheader - if the value does not match, the interceptor throws
SecurityException - the global
ExceptionHandlerturns that into a403JSON response
This is not meant to be enterprise-grade authentication. It is a lightweight teaching example that shows how Kora interceptors and configuration can work together for authorization-like checks. For
Principal-based authorization built on HttpServerPrincipalExtractor, see HTTP Server authorization.
Create the DataApiAuthConfig contract:
Create the DataApiAuthInterceptor:
package io.koraframework.guide.httpserver.advanced.controller;
import io.koraframework.common.annotation.Component;
import io.koraframework.http.server.common.interceptor.HttpServerInterceptor;
import io.koraframework.http.server.common.request.HttpServerRequest;
import io.koraframework.http.server.common.response.HttpServerResponse;
@Component
public final class DataApiAuthInterceptor implements HttpServerInterceptor {
private final DataApiAuthConfig config;
public DataApiAuthInterceptor(DataApiAuthConfig config) {
this.config = config;
}
@Override
public HttpServerResponse intercept(HttpServerRequest request, InterceptChain chain) throws Exception {
var authorization = request.headers().getFirst("authorization");
if (!this.config.value().equals(authorization)) {
throw new SecurityException("Invalid API key");
}
return chain.process(request);
}
}
package io.koraframework.guide.httpserver.advanced.controller
import io.koraframework.common.annotation.Component
import io.koraframework.http.server.common.interceptor.HttpServerInterceptor
import io.koraframework.http.server.common.request.HttpServerRequest
import io.koraframework.http.server.common.response.HttpServerResponse
@Component
class DataApiAuthInterceptor(
private val config: DataApiAuthConfig
) : HttpServerInterceptor {
override fun intercept(request: HttpServerRequest, chain: HttpServerInterceptor.InterceptChain): HttpServerResponse {
val authorization = request.headers().getFirst("authorization")
if (config.value() != authorization) {
throw SecurityException("Invalid API key")
}
return chain.process(request)
}
}
Header names are matched case-insensitively by HttpHeaders, so "authorization" also matches an Authorization: header sent by a client.
Apply it to DataController:
Configure the API key:
Add the authorization value to application.conf.
For the full configuration reference, see Configuration.
auth {
apiKey {
value = "MySecuredApiKey" //(1)!
value = ${?HTTP_ADVANCED_API_KEY} //(2)!
}
}
- Local default used when no environment variable is set.
- Optional override from
HTTP_ADVANCED_API_KEY. In HOCON a default is expressed by assigning the key twice: the second assignment is skipped when the variable is absent.
The local default makes the guide easy to run, while the environment-variable override shows the production-friendly pattern.
Blocking and Parallel Work¶
Kora HTTP handlers are synchronous, and that is a deliberate design decision rather than a limitation. Undertow dispatches every request onto a virtual thread before the generated handler calls your controller method, so blocking IO inside a route is normal: a JDBC query, an HTTP call to another service, or reading a file does not pin a platform thread.
The processors enforce that contract at compile time:
A route that returns CompletionStage<T>, Future<T>, or a reactive Publisher<T> compiles, but the annotation processor reports a warning and the value is treated as an ordinary result that
needs its own response mapper:
Return the value itself instead. When one request genuinely needs two independent calls at the same time, fan out inside the handler and join before returning:
@HttpRoute(method = HttpMethod.GET, path = "/data/summary")
@Json
public Payload summary() throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { //(1)!
var first = executor.submit(() -> remoteA.load());
var second = executor.submit(() -> remoteB.load()); //(2)!
return new Payload(first.get() + " / " + second.get()); //(3)!
}
}
- One short-lived executor per request;
close()waits for the submitted tasks. - Both calls start immediately and run on their own virtual threads.
- The handler blocks here and returns a plain value, so the route contract stays synchronous.
A suspend route is rejected outright by the symbol processor:
HTTP server controller method is invalid:
test
Problem:
Suspend methods are not supported by the HTTP server controller generator.
Fix:
Remove suspend from the controller method.
Return the value itself instead. When one request genuinely needs two independent calls at the same time, fan out inside the handler and join before returning:
@HttpRoute(method = HttpMethod.GET, path = "/data/summary")
@Json
fun summary(): Payload {
Executors.newVirtualThreadPerTaskExecutor().use { executor -> //(1)!
val first = executor.submit<String> { remoteA.load() }
val second = executor.submit<String> { remoteB.load() } //(2)!
return Payload("${first.get()} / ${second.get()}") //(3)!
}
}
- One short-lived executor per request;
close()waits for the submitted tasks. - Both calls start immediately and run on their own virtual threads.
- The handler blocks here and returns a plain value, so the route contract stays synchronous.
The same rule applies to interceptors and to HttpServerRequestMapper / HttpServerResponseMapper implementations: all of them return their result directly.
The full compiler message for suspend also suggests structured concurrency with StructuredTaskScope for this fan-out, which is a preview API and needs --enable-preview on the JDK 25 toolchain.
The executor above requires no preview flag, which is why the guide uses it.
Generated Code¶
Kora declarative HTTP controllers are compiled into HttpServerRequestHandler components.
After you run:
inspect the generated module:
For example, the generated handler for the form endpoint looks like this:
@Generated("io.koraframework.http.server.annotation.processor.ControllerModuleGenerator")
@Module
public interface DataControllerModule {
default HttpServerRequestHandler post_data_form(DataController _controller,
HttpServerRequestMapper<FormUrlEncoded> formBodyHttpRequestMapper,
HttpServerResponseMapper<String> _responseMapper,
DataApiAuthInterceptor _interceptor1) {
return HttpServerRequestHandlerImpl.of("POST", "/data/form", (_request) -> {
return _interceptor1.intercept(_request, (_request1) -> {
final FormUrlEncoded formBody;
try {
formBody = formBodyHttpRequestMapper.apply(_request1);
} catch (Exception _e) {
if (_e instanceof HttpServerResponse) throw _e;
throw HttpServerResponseException.of(400, _e);
}
var _result = _controller.processForm(formBody);
return _responseMapper.apply(_request, _result);
});
});
}
}
@Generated("io.koraframework.http.server.symbol.procesor.HttpControllerProcessor")
@Module
public interface DataControllerModule {
public fun post_data_form(
_controller: DataController,
_formBodyMapper: HttpServerRequestMapper<FormUrlEncoded>,
_responseMapper: HttpServerResponseMapper<String>,
_interceptor1: DataApiAuthInterceptor,
): HttpServerRequestHandler {
return HttpServerRequestHandlerImpl.of("POST", "/data/form") { _request ->
_interceptor1.intercept(_request) process@{ _request1 ->
val formBody = try {
(_formBodyMapper as HttpServerRequestMapper<FormUrlEncoded?>).apply(_request1)
} catch (_e: Exception) {
if (_e is HttpServerResponse) {
throw _e
}
throw HttpServerResponseException.of(400, _e)
}
if (formBody == null) {
throw HttpServerResponseException.of(400, "Parameter formBody is not nullable, but got null from mapper")
}
val _result = _controller.processForm(formBody)
return@process _responseMapper.apply(_request, _result)
}
}
}
}
This generated code is the bridge between the nice controller method and the low-level HTTP server pipeline:
HttpServerRequestHandlerImpl.of(...)registers the route method and path, and the generated method name is derived from themHttpServerRequestMapper<FormUrlEncoded>reads the request body and is injected from the graphDataApiAuthInterceptorwraps the route, andchain.process(request)is the nested lambdaHttpServerResponseMapper<String>turns the return value into an HTTP response- a mapper failure is converted into
400unless the exception is already anHttpServerResponse
Notice what is not there: no executor hop, no CompletableFuture, no callback. The handler runs top to bottom on the virtual thread Undertow assigned to the request.
This is a strong debugging technique for both developers and AI assistants: when route behavior is unclear, generated sources show the exact request pipeline that Kora compiled from annotations.
Imperative Controller¶
Most application endpoints should use declarative controllers because they are easier to read and test. Kora also allows a lower-level imperative style through HttpServerRequestHandler, which is
useful when you need direct control over the request pipeline or want to understand what generated controllers compile down to.
HttpServerRequestHandlerImpl lives in io.koraframework.http.server.common.request and offers one factory per HTTP method (get, post, put, delete, ...) plus a generic of(method, route,
handler). The handler is a HandlerFunction: it takes the HttpServerRequest and returns an HttpServerResponse.
Add this manual handler to Application.java or Application.kt:
package io.koraframework.guide.httpserver.advanced;
import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.config.hocon.HoconConfigModule;
import io.koraframework.guide.httpserver.advanced.controller.DataApiAuthConfig;
import io.koraframework.http.common.body.HttpBody;
import io.koraframework.http.server.common.request.HttpServerRequestHandler;
import io.koraframework.http.server.common.request.HttpServerRequestHandlerImpl;
import io.koraframework.http.server.common.response.HttpServerResponse;
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule;
import io.koraframework.json.common.JsonModule;
import io.koraframework.logging.logback.LogbackModule;
@KoraApp
public interface Application extends
HoconConfigModule,
JsonModule,
LogbackModule,
UndertowPublicHttpServerModule { // <----- Connected module
default HttpServerRequestHandler manualDataPingHandler(DataApiAuthConfig authConfig) {
return HttpServerRequestHandlerImpl.get("/manual/data/ping", request -> {
var authorization = request.headers().getFirst("authorization");
if (!authConfig.value().equals(authorization)) {
return HttpServerResponse.of(403, HttpBody.plaintext("Invalid API key"));
}
return HttpServerResponse.of(200, HttpBody.plaintext("manual-data-pong"));
});
}
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
package io.koraframework.guide.httpserver.advanced
import io.koraframework.application.graph.KoraApplication
import io.koraframework.common.annotation.KoraApp
import io.koraframework.config.hocon.HoconConfigModule
import io.koraframework.guide.httpserver.advanced.controller.DataApiAuthConfig
import io.koraframework.http.common.body.HttpBody
import io.koraframework.http.server.common.request.HttpServerRequestHandler
import io.koraframework.http.server.common.request.HttpServerRequestHandlerImpl
import io.koraframework.http.server.common.response.HttpServerResponse
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule
import io.koraframework.json.common.JsonModule
import io.koraframework.logging.logback.LogbackModule
@KoraApp
interface Application :
HoconConfigModule,
JsonModule,
LogbackModule,
UndertowPublicHttpServerModule { // <----- Connected module
fun manualDataPingHandler(authConfig: DataApiAuthConfig): HttpServerRequestHandler {
return HttpServerRequestHandlerImpl.get("/manual/data/ping") { request ->
val authorization = request.headers().getFirst("authorization")
if (authConfig.value() != authorization) {
HttpServerResponse.of(403, HttpBody.plaintext("Invalid API key"))
} else {
HttpServerResponse.of(200, HttpBody.plaintext("manual-data-pong"))
}
}
}
}
fun main() {
KoraApplication.run(ApplicationGraph::graph)
}
The method returns a framework handler directly:
HttpServerRequestHandlerImpl.get(...)registersGET /manual/data/ping- the lambda receives the
HttpServerRequestand returns anHttpServerResponse - the handler reads the
Authorizationheader manually - because the method is declared on the
@KoraAppinterface, its result becomes a graph component, and the router picks it up throughAll<HttpServerRequestHandler> - global interceptors such as
ExceptionHandlerstill wrap this route, because they are applied by the router and not by the generated controller module
After compilation, the generated application graph wires this handler as another node. Component numbers depend on how many components the included modules contribute, so they will differ in your build:
The important distinction is that declarative controllers generate a HttpServerRequestHandler for you, while the imperative style lets you provide that handler yourself.
Check Application¶
Try the richer createUser request with request metadata:
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-H "X-Request-ID: test-123" \
-H "User-Agent: curl-test" \
-H "Cookie: sessionId=session-42" \
-d '{"name": "John Doe", "email": "john@example.com"}'
Then call the protected DataController routes with the API key:
curl -X POST http://localhost:8080/data/form \
-H "Authorization: MySecuredApiKey" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "name=Ivan"
curl -X POST http://localhost:8080/data/upload \
-H "Authorization: MySecuredApiKey" \
-F "file=@README.md"
curl http://localhost:8080/manual/data/ping \
-H "Authorization: MySecuredApiKey"
If the Authorization header is missing or wrong, the route should return 403 with the shared ErrorResponse body. The restricted form name produces the same shape with 400:
curl -X POST http://localhost:8080/data/form \
-H "Authorization: MySecuredApiKey" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "name=admin"
# Expected output: {"message":"Form name 'admin' is restricted"}
Best Practices¶
- Introduce advanced HTTP concepts one at a time instead of mixing them into the first server example.
- Use
HttpServerRequestMapperwhen several request values belong to one typed concept. - Keep transport-specific routes in a separate controller so business controllers stay focused.
- Prefer controller-level interceptors before making behavior global.
- Use a global interceptor only for behavior that truly should affect the whole HTTP server, and always tag it with
@Tag(HttpServer.class). - Cover a global interceptor with a test: a wrong tag compiles cleanly and silently disables it.
- Use imperative
HttpServerRequestHandlersparingly, when direct request/response control is clearer than annotations. - Put simple secrets behind environment-variable overrides even in guide applications.
Summary¶
You extended the basic Kora HTTP server with:
- a typed
RequestContextMapper - a
DataControllerfor forms, multipart uploads, and advanced client helper routes - a controller-level
LoggingInterceptor - a shared
ErrorResponse - a global
ExceptionHandler - a simple API-key authorization layer on
DataController - a manual
HttpServerRequestHandlerendpoint that shows the low-level route API
What You Learned¶
- custom request mapping with
HttpServerRequestMapperand@Mapping - additional body formats with
FormUrlEncodedandFormMultipart - controller-level interceptors with
@InterceptWith - global interceptors with
@Tag(HttpServer.class) - simple header-based authorization through an interceptor and configuration
- imperative route registration with
HttpServerRequestHandlerImpl - why controller methods, interceptors, and mappers are all synchronous, and how to still run work in parallel
Troubleshooting¶
RequestContextMapper is not used:
- Check that the parameter is annotated with
@Mapping(...). - Make sure the mapper implements
HttpServerRequestMapper<T>.
Compilation fails with No component found for dependency naming the mapper or interceptor:
- A class referenced from
@Mappingor@InterceptWithis resolved from the graph, never constructed by the generated module. Add@Componentto it.
Multipart request does not work:
- Make sure the client sends
multipart/form-data. - Check that the uploaded part names match what the controller processes.
Controller-level logging does not appear:
- Check
@InterceptWith(LoggingInterceptor.class)or@InterceptWith(LoggingInterceptor::class)on the controller. - Verify the interceptor itself is a component.
Global exception handler does not run:
- Check
@Tag(HttpServer.class)on the interceptor. Any other tag compiles but is never collected by the router. - Make sure the class is also annotated with
@Component.
Protected DataController routes return 403:
- Check the
Authorizationheader value. - Make sure it matches
auth.apiKey.value. - If you use
HTTP_ADVANCED_API_KEY, remember that it overrides the local default.
The YAML API key resolves to nothing:
${?VAR:default}is not a valid combination: with?the wholeVAR:defaulttext becomes the reference name. Use${VAR:default}.
Kotlin build fails with Suspend methods are not supported by the HTTP server controller generator:
- Remove
suspendfrom the controller method and return the value directly.
What's Next?¶
- Store Files with S3 to build on the multipart and advanced HTTP request-handling model.
- HTTP Client if you have not built a client app yet.
- HTTP Client Advanced after HTTP Client, to call these richer endpoints from another Kora app.
- OpenAPI HTTP Server before OpenAPI HTTP Server Advanced, because the advanced OpenAPI guide requires both tracks.
- Observability to monitor advanced request mappings, interceptors, and error handling.
Help¶
If you get stuck:
- compare with Kora Java HTTP Server Advanced App and Kora Kotlin HTTP Server Advanced App
- revisit HTTP Server for the base controller-service-repository flow
- check the HTTP Server documentation
- check the Container documentation