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

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 DataController for 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 for DataController

What You'll Need

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-urlencoded flows
  • 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:

  1. add a request context mapper for one route
  2. add form and multipart handling in a separate controller
  3. introduce controller interceptors
  4. centralize error responses with an exception handler
  5. 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(...) from io.koraframework.common.annotation tells 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-urlencoded
  • multipart/form-data

What these formats are:

  • application/x-www-form-urlencoded is 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-data is 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:

  • UserController stays focused on user CRUD
  • DataController becomes a transport playground for alternate HTTP body formats

That keeps the business-oriented controller easier to read.

Create DataController:

src/main/java/io/koraframework/guide/httpserver/advanced/controller/DataController.java
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) {}
}
src/main/kotlin/io/koraframework/guide/httpserver/advanced/controller/DataController.kt
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:

src/main/java/io/koraframework/guide/httpserver/advanced/controller/LoggingInterceptor.java
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);
        }
    }
}
src/main/kotlin/io/koraframework/guide/httpserver/advanced/controller/LoggingInterceptor.kt
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 chain
  • chain.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/finally is 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:

@Component
@HttpController
@InterceptWith(LoggingInterceptor.class)
public final class UserController {
    // existing routes stay the same
}
@Component
@HttpController
@InterceptWith(LoggingInterceptor::class)
class UserController(
    private val userService: UserService
) {
    // existing routes stay the same
}

@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:

src/main/java/io/koraframework/guide/httpserver/advanced/dto/ErrorResponse.java
package io.koraframework.guide.httpserver.advanced.dto;

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

@Json
public record ErrorResponse(String message) {}
src/main/kotlin/io/koraframework/guide/httpserver/advanced/dto/ErrorResponse.kt
package io.koraframework.guide.httpserver.advanced.dto

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

@Json
data class ErrorResponse(
    val message: String
)

Create a small exception for a deliberately restricted form name:

src/main/java/io/koraframework/guide/httpserver/advanced/controller/RestrictedFormNameException.java
package io.koraframework.guide.httpserver.advanced.controller;

public final class RestrictedFormNameException extends RuntimeException {

    public RestrictedFormNameException(String name) {
        super("Form name '" + name + "' is restricted");
    }
}
src/main/kotlin/io/koraframework/guide/httpserver/advanced/controller/RestrictedFormNameException.kt
package io.koraframework.guide.httpserver.advanced.controller

class RestrictedFormNameException(name: String) : RuntimeException("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;
}
@HttpRoute(method = HttpMethod.POST, path = "/data/form")
fun processForm(formBody: FormUrlEncoded): String {
    val name = formBody.get("name")?.values()?.firstOrNull() ?: "World"
    if (name.equals("admin", ignoreCase = true)) {
        throw 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.

src/main/java/io/koraframework/guide/httpserver/advanced/controller/ExceptionHandler.java
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))));
    }
}
src/main/kotlin/io/koraframework/guide/httpserver/advanced/controller/ExceptionHandler.kt
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:

@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
public UserResponse getUser(@Path String userId) {
    return userService.getUser(userId)
            .orElseThrow(() -> HttpServerResponseException.of(404, "User not found: " + userId));
}
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
fun getUser(@Path userId: String): UserResponse =
    userService.getUser(userId) ?: throw HttpServerResponseException.of(404, "User not found: $userId")

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 ErrorResponse shape

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 Authorization header
  • if the value does not match, the interceptor throws SecurityException
  • the global ExceptionHandler turns that into a 403 JSON 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:

src/main/java/io/koraframework/guide/httpserver/advanced/controller/DataApiAuthConfig.java
package io.koraframework.guide.httpserver.advanced.controller;

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

@ConfigSource("auth.apiKey")
public interface DataApiAuthConfig {

    String value();
}
src/main/kotlin/io/koraframework/guide/httpserver/advanced/controller/DataApiAuthConfig.kt
package io.koraframework.guide.httpserver.advanced.controller

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

@ConfigSource("auth.apiKey")
interface DataApiAuthConfig {
    fun value(): String
}

Create the DataApiAuthInterceptor:

src/main/java/io/koraframework/guide/httpserver/advanced/controller/DataApiAuthInterceptor.java
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);
    }
}
src/main/kotlin/io/koraframework/guide/httpserver/advanced/controller/DataApiAuthInterceptor.kt
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:

@Component
@HttpController
@InterceptWith(DataApiAuthInterceptor.class)
public final class DataController {
    // routes stay the same
}
@Component
@HttpController
@InterceptWith(DataApiAuthInterceptor::class)
class DataController {
    // routes stay the same
}

Configure the API key:

Add the authorization value to application.conf.

For the full configuration reference, see Configuration.

src/main/resources/application.conf
auth {
  apiKey {
    value = "MySecuredApiKey" //(1)!
    value = ${?HTTP_ADVANCED_API_KEY} //(2)!
  }
}
  1. Local default used when no environment variable is set.
  2. 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.
src/main/resources/application.yaml
auth:
  apiKey:
    value: ${HTTP_ADVANCED_API_KEY:MySecuredApiKey} #(1)!
  1. Reads HTTP_ADVANCED_API_KEY and falls back to the shown default. Do not write ${?VAR:default}: with ? the whole VAR:default text is treated as the reference name and the key resolves to nothing.

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:

Method return type is CompletionStage<T> which is unsupported and has no meaning

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)!
    }
}
  1. One short-lived executor per request; close() waits for the submitted tasks.
  2. Both calls start immediately and run on their own virtual threads.
  3. 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)!
    }
}
  1. One short-lived executor per request; close() waits for the submitted tasks.
  2. Both calls start immediately and run on their own virtual threads.
  3. 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:

./gradlew clean classes

inspect the generated module:

build/generated/sources/annotationProcessor/java/main/io/koraframework/guide/httpserver/advanced/controller/DataControllerModule.java
build/generated/ksp/main/kotlin/io/koraframework/guide/httpserver/advanced/controller/DataControllerModule.kt

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 them
  • HttpServerRequestMapper<FormUrlEncoded> reads the request body and is injected from the graph
  • DataApiAuthInterceptor wraps the route, and chain.process(request) is the nested lambda
  • HttpServerResponseMapper<String> turns the return value into an HTTP response
  • a mapper failure is converted into 400 unless the exception is already an HttpServerResponse

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:

src/main/java/io/koraframework/guide/httpserver/advanced/Application.java
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);
    }
}
src/main/kotlin/io/koraframework/guide/httpserver/advanced/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
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(...) registers GET /manual/data/ping
  • the lambda receives the HttpServerRequest and returns an HttpServerResponse
  • the handler reads the Authorization header manually
  • because the method is declared on the @KoraApp interface, its result becomes a graph component, and the router picks it up through All<HttpServerRequestHandler>
  • global interceptors such as ExceptionHandler still 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:

component44 = graphDraw.addNode(_type_of_component44,
    null,
    null,
    List.of(component29),
    List.of(component29),
    List.of(),
    g -> impl.manualDataPingHandler(
      g.get(ApplicationGraph.holder0.component29)
    ));
component28 = graphDraw.addNode(map["component28"],
  null,
  null,
  listOf(component27),
  listOf(component27),
  listOf(),
  { impl.manualDataPingHandler(
    it.get(holder0.component27)
  ) }
)

The important distinction is that declarative controllers generate a HttpServerRequestHandler for you, while the imperative style lets you provide that handler yourself.

Check Application

./gradlew clean classes
./gradlew test
./gradlew run

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 HttpServerRequestMapper when 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 HttpServerRequestHandler sparingly, 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 DataController for 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 HttpServerRequestHandler endpoint that shows the low-level route API

What You Learned

  • custom request mapping with HttpServerRequestMapper and @Mapping
  • additional body formats with FormUrlEncoded and FormMultipart
  • 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 @Mapping or @InterceptWith is resolved from the graph, never constructed by the generated module. Add @Component to 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 Authorization header 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 whole VAR:default text becomes the reference name. Use ${VAR:default}.

Kotlin build fails with Suspend methods are not supported by the HTTP server controller generator:

  • Remove suspend from the controller method and return the value directly.

What's Next?

Help

If you get stuck: