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

HTTP server

The HTTP server module describes the incoming HTTP boundary of an application: accepting a request, parsing parameters, reading the body, selecting a handler, creating a response, telemetry, and interceptors. In Kora, controllers can be described declaratively with @HttpController and @HttpRoute, or handlers can be registered imperatively with HttpServerRequestHandler.

The declarative approach fits most APIs: the method signature describes the HTTP contract, and Kora creates the handler at compile time without using Reflection at runtime. The imperative approach is useful for low-level or dynamic routes where it is easier to process the request manually.

Recommendation

We recommend using an approach where OpenAPI file is primary contract and controllers are created from it using a OpenAPI generator. This approach allows you to achieve consistency between the consumer and owner of the contract and allows you to share this contract to create clients for it using the same approach. For more information about the generator, see the section on generating from OpenAPI.

For a step-by-step walkthrough before the reference details, see HTTP Server and Advanced HTTP Server.

Dependency

Implementation is based on Undertow. Undertow is a lightweight open-source web server for Java applications. It is built on asynchronous and non-blocking I/O operations using NIO, which ensures high performance and low resource consumption.

Dependency build.gradle:

implementation "ru.tinkoff.kora:http-server-undertow"

Module:

@KoraApp
public interface Application extends UndertowHttpServerModule { }

Dependency build.gradle.kts:

implementation("ru.tinkoff.kora:http-server-undertow")

Module:

@KoraApp
interface Application : UndertowHttpServerModule

Configuration

Basic HTTP server configuration parameters:

httpServer {
    publicApiHttpPort = 8080 //(1)!
    privateApiHttpPort = 8085 //(2)!
    virtualThreadsEnabled = false //(3)!
    maxRequestBodySize = "256MiB" //(4)!
}
  1. Public HTTP server port (default: 8080)
  2. Private HTTP server port (default: 8085)
  3. Enables virtual threads for blocking request processing instead of the blockingThreads pool, requires Java 21+ (default: false)
  4. Maximum allowed size of incoming request body (default: 256MiB)
httpServer:
  publicApiHttpPort: 8080 #(1)!
  privateApiHttpPort: 8085 #(2)!
  virtualThreadsEnabled: false #(3)!
  maxRequestBodySize: "256MiB" #(4)!
  1. Public HTTP server port (default: 8080)
  2. Private HTTP server port (default: 8085)
  3. Enables virtual threads for blocking request processing instead of the blockingThreads pool, requires Java 21+ (default: false)
  4. Maximum allowed size of incoming request body (default: 256MiB)
Full Configuration

Example of the complete configuration described in the HttpServerConfig class (default or example values are specified):

httpServer {
    publicApiHttpPort = 8080 //(1)!
    privateApiHttpPort = 8085 //(2)!
    privateApiHttpMetricsPath = "/metrics" //(3)!
    privateApiHttpReadinessPath = "/system/readiness" //(4)!
    privateApiHttpLivenessPath = "/system/liveness" //(5)!
    ignoreTrailingSlash = false //(6)!
    ioThreads = 2 //(7)!
    blockingThreads = 2 //(8)!
    shutdownWait = "30s" //(9)!
    threadKeepAliveTimeout = "60s" //(10)!
    socketReadTimeout = "0s" //(11)!
    socketWriteTimeout = "0s" //(12)!
    socketKeepAliveEnabled = false //(13)!
    virtualThreadsEnabled = false //(14)!
    maxRequestBodySize = "256MiB" //(15)!
    telemetry {
        logging {
            enabled = false //(16)!
            stacktrace = true //(17)!
            mask = "***" //(18)!
            maskQueries = [ ] //(19)!
            maskHeaders = [ "authorization", "cookie", "set-cookie" ] //(20)!
            pathTemplate = true //(21)!
        }
        metrics {
            enabled = true //(22)!
            slo = [ 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000 ] //(23)!
            tags = { // (24)!
                "key1" = "value1"
                "key2" = "value2"
            }
        }
        tracing {
            enabled = true //(25)!
            attributes = { // (26)!
                "key1" = "value1"
                "key2" = "value2"
            }
        }
    }
}
  1. Public HTTP server port (default: 8080)
  2. Private HTTP server port (default: 8085)
  3. Path to get metrics on the private server (default: /metrics)
  4. Path to get readiness probe status on the private server (default: /system/readiness)
  5. Path to get liveness probe status on the private server (default: /system/liveness)
  6. Whether to ignore a trailing / in the path: when enabled, /my/path and /my/path/ are treated as the same route (default: false)
  7. Number of network I/O threads (default: number of available processors, but not less than 2)
  8. Number of threads for blocking request processing (default: min(max(available processors, 2) * 8, 200))
  9. Time to wait for processing before server shutdown during graceful shutdown (default: 30s)
  10. Maximum idle lifetime of a request handler thread (default: 60s)
  11. Maximum time to wait for reading data from a socket or connection; 0s disables the timeout (default: 0s)
  12. Maximum time to wait for writing data to a socket or connection; 0s disables the timeout (default: 0s)
  13. Whether to enable TCP keep-alive for a socket or connection (default: false)
  14. Enables virtual threads for blocking request processing instead of the blockingThreads pool, requires Java 21+ (default: false)
  15. Maximum allowed size of an incoming request body (default: 256MiB)
  16. Enables module logging (default: false)
  17. Enables call stack logging on exception (default: true)
  18. Mask used to hide specified headers and request or response parameters (default: ***)
  19. List of request parameters to hide (default: [])
  20. List of request or response headers to hide (default: [ "authorization", "cookie", "set-cookie" ])
  21. Whether to use the request path template in logs; when not specified, the template is always used except at TRACE, where the full path is used (default not specified, optional)
  22. Enables module metrics (default: true)
  23. Configures SLO for metrics (default: ru.tinkoff.kora.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO)
  24. Configures metric tags (default: {})
  25. Enables module tracing (default: true)
  26. Configures tracing attributes (default: {})
httpServer:
  publicApiHttpPort: 8080 #(1)!
  privateApiHttpPort: 8085 #(2)!
  privateApiHttpMetricsPath: "/metrics" #(3)!
  privateApiHttpReadinessPath: "/system/readiness" #(4)!
  privateApiHttpLivenessPath: "/system/liveness" #(5)!
  ignoreTrailingSlash: false #(6)!
  ioThreads: 2 #(7)!
  blockingThreads: 2 #(8)!
  shutdownWait: "30s" #(9)!
  threadKeepAliveTimeout: "60s" #(10)!
  socketReadTimeout: "0s" #(11)!
  socketWriteTimeout: "0s" #(12)!
  socketKeepAliveEnabled: false #(13)!
  virtualThreadsEnabled: false #(14)!
  maxRequestBodySize: "256MiB" #(15)!
  telemetry:
    logging:
      enabled: false #(16)!
      stacktrace: true #(17)!
      mask: "***" #(18)!
      maskQueries: [ ] #(19)!
      maskHeaders: [ "authorization", "cookie", "set-cookie" ] #(20)!
      pathTemplate: true #(21)!
    metrics:
      enabled: true #(22)!
      slo: [ 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000 ] #(23)!
      tags: #(24)!
        key1: value1
        key2: value2
    tracing:
      enabled: true #(25)!
      attributes: #(26)!
        key1: value1
        key2: value2
  1. Public HTTP server port (default: 8080)
  2. Private HTTP server port (default: 8085)
  3. Path to get metrics on the private server (default: /metrics)
  4. Path to get readiness probe status on the private server (default: /system/readiness)
  5. Path to get liveness probe status on the private server (default: /system/liveness)
  6. Whether to ignore a trailing / in the path: when enabled, /my/path and /my/path/ are treated as the same route (default: false)
  7. Number of network I/O threads (default: number of available processors, but not less than 2)
  8. Number of threads for blocking request processing (default: min(max(available processors, 2) * 8, 200))
  9. Time to wait for processing before server shutdown during graceful shutdown (default: 30s)
  10. Maximum idle lifetime of a request handler thread (default: 60s)
  11. Maximum time to wait for reading data from a socket or connection; 0s disables the timeout (default: 0s)
  12. Maximum time to wait for writing data to a socket or connection; 0s disables the timeout (default: 0s)
  13. Whether to enable TCP keep-alive for a socket or connection (default: false)
  14. Enables virtual threads for blocking request processing instead of the blockingThreads pool, requires Java 21+ (default: false)
  15. Maximum allowed size of an incoming request body (default: 256MiB)
  16. Enables module logging (default: false)
  17. Enables call stack logging on exception (default: true)
  18. Mask used to hide specified headers and request or response parameters (default: ***)
  19. List of request parameters to hide (default: [])
  20. List of request or response headers to hide (default: [ "authorization", "cookie", "set-cookie" ])
  21. Whether to use the request path template in logs; when not specified, the template is always used except at TRACE, where the full path is used (default not specified, optional)
  22. Enables module metrics (default: true)
  23. Configures SLO for metrics (default: ru.tinkoff.kora.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO)
  24. Configures metric tags (default: {})
  25. Enables module tracing (default: true)
  26. Configures tracing attributes (default: {})

Module metrics are described in the Metrics Reference section.

Kora provides fine-grained control over the Undertow HTTP server through two dedicated configuration interfaces: UndertowConfigurer and HttpHandlerConfigurer. They allow configuring server behavior and the request processing pipeline without sacrificing integration with Kora's modular architecture.

SomeController declarative

The @HttpController annotation should be used to create a controller, and the @Component annotation should be used to register it as a dependency. The @HttpRoute annotation is responsible for specifying the HTTP path and method for a particular handler method.

@Component //(1)!
@HttpController //(2)!
public final class SomeController {

    //(3)!
    @HttpRoute(method = HttpMethod.POST,  //(4)!
               path = "/hello/world")  //(5)!
    public String helloWorld() {
        return "Hello World";
    }
}
  1. Indicates that the class is a component and should be registered in the application dependency container
  2. Indicates that the class is a controller and contains HTTP handlers
  3. Indicates that the method is a path handler in the controller
  4. Indicates the type of the handler HTTP method
  5. Indicates the path of the handler method
@Component //(1)!
@HttpController //(2)!
class SomeController {

    //(3)!
    @HttpRoute(method = HttpMethod.POST,  //(4)!
               path = "/hello/world") //(5)!
    fun helloWorld(): String {
        return "Hello World"
    }
}
  1. Indicates that the class is a component and should be registered in the application dependency container
  2. Indicates that the class is a controller and contains HTTP handlers
  3. Indicates that the method is a path handler in the controller
  4. Indicates the type of the handler HTTP method
  5. Indicates the path of the handler method

Request

This section describes how an HTTP request is converted into controller method arguments. Special annotations are used for request parts, and the request body is passed as an argument without such an annotation.

String parameter conversion

Values from paths, query parameters, headers, and cookie arrive as strings. Kora uses StringParameterReader<T> to convert a string into the target type:

public interface StringParameterReader<T> {
    T read(String string);
}

StringParameterReader<T> is looked up as a graph component by the exact parameter type. If the parameter is declared as List<T> or Set<T>, the converter is applied to every value separately.

Out of the box, Kora supports String, Boolean, Integer, Long, Float, Double, UUID, BigInteger, BigDecimal, Duration, LocalDate, LocalTime, LocalDateTime, OffsetTime, OffsetDateTime, ZonedDateTime, and enum. For enum, the default mapping uses the value name via Enum.name(). If a value cannot be converted, the request is completed with a 400 response through HttpServerResponseException.

public record UserId(long value) {}

@Module
public interface UserIdModule {

    default StringParameterReader<UserId> userIdStringParameterReader() {
        return StringParameterReader.of(
            value -> new UserId(Long.parseLong(value)),
            value -> "Invalid user id: " + value
        );
    }
}
data class UserId(val value: Long)

@Module
interface UserIdModule {

    fun userIdStringParameterReader(): StringParameterReader<UserId> {
        return StringParameterReader.of(
            { value -> UserId(value.toLong()) },
            { value -> "Invalid user id: $value" }
        )
    }
}

After registering the converter, the custom type can be used in controller parameters:

@HttpRoute(method = HttpMethod.GET, path = "/users/{id}")
public User get(@Path("id") UserId id) {
    return userService.get(id);
}

Path parameter

@Path - denotes the value of the request path part, the parameter itself is specified in {path} in the path and the name of the parameter is specified in value or defaults to the name of the method argument. The value is converted through StringParameterReader<T>, so both built-in and custom types can be used.

@Component
@HttpController
public final class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/{pathName}")
    public String helloWorld(@Path("pathName") String pathValue) {
        return "Hello World";
    }
}
@Component
@HttpController
class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/{pathName}")
    fun helloWorld(
        @Path("pathName") pathValue: String
    ): String {
        return "Hello World";
    }
}

Query parameter

@Query - value of the query parameter, the name of the parameter is specified in value or is equal to the name of the method argument by default. Single values, List<T>, and Set<T> are supported. List<T> keeps all parameter values, while Set<T> removes duplicates and preserves the order of first occurrence.

@Component
@HttpController
public final class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    public String helloWorld(@Query("queryName") String queryValue,
                             @Query("queryNameList") List<String> queryValues) {
        return "Hello World";
    }
}
@Component
@HttpController
class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    fun helloWorld(
        @Query("queryName") queryValue: String,
        @Query("queryNameList") queryValues: List<String>
    ): String {
        return "Hello World";
    }
}

Request header

@Header - value of request header, the parameter name is specified in value or defaults to the method argument name. Single values, List<T>, and Set<T> are supported. List<T> and Set<T> use all values of the header.

@Component
@HttpController
public final class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    public String helloWorld(@Header("headerName") String headerValue,
                             @Header("headerNameList") List<String> headerValues) {
        return "Hello World";
    }
}
@Component
@HttpController
class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    operator fun helloWorld(
        @Header("headerName") headerValue: String,
        @Header("headerNameList") headerValues: List<String>
    ): String {
        return "Hello World";
    }
}

Request body

Specifying the request body requires using a method argument without special annotations. By default, byte[], ByteBuffer, String, FormUrlEncoded, FormMultipart, and custom types through HttpServerRequestMapper<T> are supported.

JSON

To indicate that the body is JSON and requires an automatically created and injected JsonReader<T>, use the @Json annotation:

@Component
@HttpController
public final class SomeController {

    public record Request(String name) {}

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    public String helloWorld(@Json Request body) { //(1)!
        return "Hello World";
    }
}
  1. Specifies that the body should be read as JSON
@Component
@HttpController
class SomeController {

    data class Request(val name: String)

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    fun helloWorld(@Json body: Request): String { //(1)!
        return "Hello World"
    }
}
  1. Specifies that the body should be read as JSON

The JSON module is required.

Form UrlEncoded

You can use FormUrlEncoded as the body argument type and it will be processed as form data.

@Component
@HttpController
public final class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    public String helloWorld(FormUrlEncoded body) {
        return "Hello World";
    }
}
@Component
@HttpController
class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    fun helloWorld(body: FormUrlEncoded): String {
        return "Hello World"
    }
}
Form Multipart

You can use FormMultipart as the body argument type and it will be treated as a binary form.

@Component
@HttpController
public final class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    public String helloWorld(FormMultipart body) {
        return "Hello World";
    }
}
@Component
@HttpController
class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    fun helloWorld(body: FormMultipart): String {
        return "Hello World"
    }
}

@Cookie - Cookie value, the parameter name is specified in value or defaults to the method argument name. The value can be received as String, as a Cookie type with name, value, and attributes, or as another type through StringParameterReader<T>.

@Component
@HttpController
public final class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    public String helloWorld(@Cookie("cookieName") String cookieValue) {
        return "Hello World";
    }
}
@Component
@HttpController
class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    operator fun helloWorld(
        @Cookie("cookieName") cookieValue: String
    ): String {
        return "Hello World";
    }
}

Custom parameter

If a method argument needs to be assembled from the request manually, use the HttpServerRequestMapper<T> interface. This is useful for user context, authorization, complex header validation, or several request parts at once:

@Component
@HttpController
public final class SomeController {

    public record UserPrincipal(String userId, String traceId) {}

    public static final class RequestMapper implements HttpServerRequestMapper<UserPrincipal> {

        @Override
        public UserPrincipal apply(HttpServerRequest request) {
            return new UserPrincipal(request.headers().getFirst("x-user-id"), request.headers().getFirst("x-trace-id"));
        }
    }

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    public String get(@Mapping(RequestMapper.class) UserPrincipal context) {
        return "Hello World";
    }
}
@Component
@HttpController
class MapperRequestController {

    data class UserPrincipal(val userId: String?, val traceId: String?)

    class RequestMapper : HttpServerRequestMapper<UserPrincipal> {
        override fun apply(request: HttpServerRequest): UserPrincipal {
            return UserPrincipal(
                request.headers().getFirst("x-user-id"),
                request.headers().getFirst("x-trace-id")
            )
        }
    }

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    @Mapping(RequestMapper::class)
    operator fun get(@Mapping(RequestMapper::class) context: UserPrincipal): String {
        return "Hello World"
    }
}

Required parameters

By default, all arguments declared in a method are required. If a required value is missing in the request, Kora returns a 400 response.

By default, all method arguments that do not use the Kotlin Nullability syntax are required. If a required value is missing in the request, Kora returns a 400 response.

Optional parameters

If a method argument is optional, meaning it may be missing in the request, use @Nullable or Optional<T> for single values:

@Component
@HttpController
public final class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    public String helloWorld(@Nullable @Query("queryName") String queryValue) { //(1)!
        return "Hello World";
    }
}
  1. Any @Nullable annotation will do, for example javax.annotation.Nullable, jakarta.annotation.Nullable, or org.jetbrains.annotations.Nullable.

Use the Kotlin Nullability syntax and mark such a parameter as optional:

@Component
@HttpController
class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    fun helloWorld(@Query("queryName") queryValue: String?): String {
        return "Hello World"
    }
}

Response

By default, standard return value types can be used: byte[], ByteBuffer, String. They are processed with status 200 and the corresponding response content type header.

If the status, headers, or body must be specified manually, the method can return HttpServerResponse. The main HttpServerResponse contract consists of a response code, headers, and an optional body:

public interface HttpServerResponse {
    int code();
    MutableHttpHeaders headers();
    @Nullable
    HttpBodyOutput body();
}
@Component
@HttpController
public final class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    public HttpServerResponse helloWorld() {
        return HttpServerResponse.of(
                200, //(1)!
                HttpHeaders.of("headerName", "headerValue"), //(2)!
                HttpBody.plaintext("Hello World") //(3)!
        ); 
    }
}
  1. HTTP response status code
  2. Response headers
  3. Response body
@Component
@HttpController
class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    fun helloWorld(): HttpServerResponse {
        return HttpServerResponse.of(
            200, //(1)!
            HttpHeaders.of("headerName", "headerValue"), //(2)!
            HttpBody.plaintext("Hello World") //(3)!
        )
    }
}
  1. HTTP response status code
  2. Response headers
  3. Response body

JSON

If the response should be returned as JSON, use the @Json annotation on the method. Kora will find or create JsonWriter<T> for the response type:

@Component
@HttpController
public final class SomeController {

    public record Response(String greeting) {}

    @Json //(1)!
    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    public Response helloWorld() {
        return new Response("Hello World");
    }
}
  1. Specifies that the response should be in JSON format
@Component
@HttpController
class SomeController {

    data class Response(val greeting: String)

    @Json //(1)!
    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    fun helloWorld(): Response {
        return Response("Hello World")
    }
}
  1. Specifies that the response should be in JSON format

The JSON module is required.

Response entity

If the body, headers, and response status code should be returned together, use HttpResponseEntity<T>, a wrapper around the response body.

Below is an example similar to the JSON example with the HttpResponseEntity wrapper:

@Component
@HttpController
public final class SomeController {

    public record Response(String greeting) {}

    @Json
    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    public HttpResponseEntity<Response> helloWorld() {
        return HttpResponseEntity.of(200, HttpHeaders.of("myHeader", "12345"), new Response("Hello World"));
    }
}
@Component
@HttpController
class SomeController {

    data class Response(val greeting: String)

    @Json
    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    fun helloWorld(): HttpResponseEntity<Response> {
        return HttpResponseEntity.of(200, HttpHeaders.of("myHeader", "12345"), Response("Hello World"));
    }
}

Respond exception

If processing should be interrupted and an error should be returned immediately, throw HttpServerResponseException. It is both an exception and an HttpServerResponse, so it can be thrown from a controller, service, or parameter converter.

The HttpServerResponseException.of(...) factory methods allow specifying the status code, response text, cause, and headers. The response body is written as text/plain; charset=utf-8.

@Component
@HttpController
public final class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/{pathName}")
    public String helloWorld(@Path("pathName") String pathValue) {
        if("null".equals(pathValue)) {
            throw HttpServerResponseException.of(400, "Bad request");
        }
        return "OK";
    }
}
@Component
@HttpController
class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/hello/{pathName}")
    fun helloWorld(@Path("pathName") pathValue: String): String {
        if ("null" == pathValue) {
            throw HttpServerResponseException.of(400, "Bad request")
        }
        return "OK"
    }
}

Custom response

If the response needs to be created in a custom way, use the HttpServerResponseMapper<T> interface. It receives Context, the original HttpServerRequest, and the controller method result, and returns a ready HttpServerResponse:

@Component
@HttpController
public final class SomeController {

    public record HelloWorldResponse(String greeting, String name) {}

    public static final class ResponseMapper implements HttpServerResponseMapper<HelloWorldResponse> {

        @Override
        public HttpServerResponse apply(Context ctx, HttpServerRequest request, HelloWorldResponse result) {
            return HttpServerResponse.of(200, HttpBody.plaintext(result.greeting() + " - " + result.name()));
        }
    }

    @Mapping(ResponseMapper.class)
    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    public HelloWorldResponse helloWorld() {
        return new HelloWorldResponse("Hello World", "Bob");
    }
}
@Component
@HttpController
class SomeController {

    data class HelloWorldResponse(val greeting: String, val name: String)

    class ResponseMapper : HttpServerResponseMapper<HelloWorldResponse> {
        fun apply(ctx: Context, request: HttpServerRequest, result: HelloWorldResponse): HttpServerResponse {
            return HttpServerResponse.of(200, HttpBody.plaintext(result.greeting + " - " + result.name))
        }
    }

    @Mapping(ResponseMapper::class)
    @HttpRoute(method = HttpMethod.POST, path = "/hello/world")
    fun helloWorld(): HelloWorldResponse {
        return HelloWorldResponse("Hello World", "Bob")
    }
}

Signatures

Available signatures for declarative HTTP handler methods out of the box:

The T refers to the type of the return value.

By T we mean the type of the return value.

Interceptors

Interceptors can be created to change behavior or add shared logic around request processing. Use the HttpServerInterceptor interface:

public interface HttpServerInterceptor {
    CompletionStage<HttpServerResponse> intercept(Context context, HttpServerRequest request, InterceptChain chain) throws Exception;

    interface InterceptChain {
        CompletionStage<HttpServerResponse> process(Context ctx, HttpServerRequest request) throws Exception;
    }
}

An interceptor receives the current Context, HttpServerRequest, and the chain of further processing. To pass the request further, call chain.process(context, request). If the interceptor returns a response itself, the controller handler is not called.

Interceptors can be used on:

  • Specific controller methods
  • Entire controller
  • All controllers at once: register the interceptor component with the @Tag(HttpServerModule.class) tag; there can be several global interceptors
@Component
@HttpController
public final class SomeController {

    public static final class MethodInterceptor implements HttpServerInterceptor {

        @Override
        public CompletionStage<HttpServerResponse> intercept(Context context, 
                                                             HttpServerRequest request, 
                                                             InterceptChain chain) throws Exception {
            return chain.process(context, request);
        }
    }

    @InterceptWith(MethodInterceptor.class)
    @HttpRoute(method = HttpMethod.POST, path = "/intercepted")
    public String helloWorld() {
        return "Hello World";
    }
}
@Component
@HttpController
class SomeController {

    class MethodInterceptor : HttpServerInterceptor {

        override fun intercept(
            context: Context,
            request: HttpServerRequest,
            chain: HttpServerInterceptor.InterceptChain
        ): CompletionStage<HttpServerResponse> {
            return chain.process(context, request)
        }
    }

    @InterceptWith(MethodInterceptor::class)
    @HttpRoute(method = HttpMethod.POST, path = "/intercepted")
    fun helloWorld(): String {
        return "Hello World"
    }
}

Error handling

Error handling for all HTTP responses can also be implemented through an interceptor. Below is a simple example of such an interceptor.

@Tag(HttpServerModule.class)
@Component
public final class ErrorInterceptor implements HttpServerInterceptor {

    @Override
    public CompletionStage<HttpServerResponse> intercept(Context context, 
                                                         HttpServerRequest request, 
                                                         InterceptChain chain) throws Exception {
        return chain.process(context, request).exceptionally(e -> {
            if(e instanceof CompletionException) {
                e = e.getCause();
            }
            if (e instanceof HttpServerResponseException ex) {
                return ex;
            }

            var body = HttpBody.plaintext(e.getMessage());
            if (e instanceof IllegalArgumentException) {
                return HttpServerResponse.of(400, body);
            } else if (e instanceof TimeoutException) {
                return HttpServerResponse.of(408, body);
            } else {
                return HttpServerResponse.of(500, body);
            }
        });
    }
}
@Tag(HttpServerModule.class)
@Component
class ErrorInterceptor : HttpServerInterceptor {

    override fun intercept(
        context: Context,
        request: HttpServerRequest,
        chain: HttpServerInterceptor.InterceptChain
    ): CompletionStage<HttpServerResponse> {
        return chain.process(context, request).exceptionally { e ->
            val error = if (e is CompletionException) e.cause!! else e
            if (error is HttpServerResponseException) {
                return@exceptionally error
            }

            val body = HttpBody.plaintext(error.message)
            when (error) {
                is IllegalArgumentException -> HttpServerResponse.of(400, body)
                is TimeoutException -> HttpServerResponse.of(408, body)
                else -> HttpServerResponse.of(500, body)
            }
        }
    }
}

SomeController imperative

In order to create a controller, implement the HttpServerRequestHandler.HandlerFunction interface, and then register it in the HttpServerRequestHandler handler.

The following example shows how to handle all the described declarative request parameters from the examples above:

public interface SomeModule {

    default HttpServerRequestHandler someHttpHandler() {
        return HttpServerRequestHandlerImpl.of(HttpMethod.POST, //(1)!
                                               "/hello/{world}", //(2)!
                                               (context, request) -> {
            var path = RequestHandlerUtils.parseStringPathParameter(request, "world");
            var query = RequestHandlerUtils.parseOptionalStringQueryParameter(request, "query");
            var queries = RequestHandlerUtils.parseOptionalStringListQueryParameter(request, "Queries");
            var header = RequestHandlerUtils.parseOptionalStringHeaderParameter(request, "header");
            var headers = RequestHandlerUtils.parseOptionalStringListHeaderParameter(request, "Headers");
            return CompletableFuture.completedFuture(HttpServerResponse.of(200, HttpBody.plaintext("Hello World")));
        });
    }
}
  1. Specifies the HTTP method type of the handler method
  2. Indicates the path of the handler method
interface SomeModule {

    fun someHttpHandler(): HttpServerRequestHandler? {
        return HttpServerRequestHandlerImpl.of(
            HttpMethod.POST, //(1)!
            "/hello/{world}" //(2)!
        ) { context: Context, request: HttpServerRequest ->
            val path = RequestHandlerUtils.parseStringPathParameter(request, "world")
            val query = RequestHandlerUtils.parseOptionalStringQueryParameter(request, "query")
            val queries = RequestHandlerUtils.parseOptionalStringListQueryParameter(request, "Queries")
            val header = RequestHandlerUtils.parseOptionalStringHeaderParameter(request, "header")
            val headers = RequestHandlerUtils.parseOptionalStringListHeaderParameter(request, "Headers")
            CompletableFuture.completedFuture(HttpServerResponse.of(200, HttpBody.plaintext("Hello World")))
        }
    }
}
  1. Specifies the HTTP method type of the handler method
  2. Indicates the path of the handler method

Authorization

Kora provides a mechanism for extracting authorization context from HTTP requests via the HttpServerPrincipalExtractor interface. This interface allows implementing any authentication scheme: Basic/ApiKey/Bearer/OAuth.

How It Works

HttpServerPrincipalExtractor<T> extracts a token from the request (usually from the Authorization header) and returns a Principal object. The obtained Principal is stored in the request Context and can be retrieved anywhere during request processing via Principal.current().

public interface HttpServerPrincipalExtractor<T extends Principal> {
    CompletionStage<T> extract(HttpServerRequest request, @Nullable String value);
}

Where: - request — the current HTTP request, from which additional data (headers, parameters) can be extracted - value — the token value extracted from the Authorization header (or another source) - T extends Principal — the type of authorization context that will be stored in the Context

Custom Principal

Use can create a simple principal for API if needed with or without fields:

public record ApiPrincipal(String client) implements Principal {}
data class ApiPrincipal(val client: String) : Principal

To pass additional authorization information (userId, roles, scope), create a custom Principal implementation:

public record UserPrincipal(String userId, List<String> roles) implements Principal {}
data class UserPrincipal(val userId: String, val roles: List<String>) : Principal

If scope handling is required, use the PrincipalWithScopes interface:

public record ScopedUser(String userId, Collection<String> scopes) implements PrincipalWithScopes {}
data class ScopedUser(val userId: String, val scopes: Collection<String>) : PrincipalWithScopes

Basic Example

Simple example of extracting an API key from the Authorization header:

@Module
public interface AuthModule {

    @ConfigSource("auth.apiKey")
    interface ApiKeyAuthConfig {
        String value();
    }

    default HttpServerPrincipalExtractor<Principal> apiKeyExtractor(ApiKeyAuthConfig config) {
        return (request, value) -> {
            if (value == null || !config.value().equals(value)) {
                return CompletableFuture.failedFuture(
                    new IllegalAccessException("Invalid API key")
                );
            }
            return CompletableFuture.completedFuture(new ApiPrincipal("api-client"));
        };
    }
}
@Module
interface AuthModule {

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

    fun apiKeyExtractor(config: ApiKeyAuthConfig): HttpServerPrincipalExtractor<Principal> {
        return HttpServerPrincipalExtractor { request, value ->
            if (value == null || config.value() != value) {
                return@HttpServerPrincipalExtractor CompletableFuture.failedFuture(
                    IllegalAccessException("Invalid API key")
                )
            }
            CompletableFuture.completedFuture(ApiPrincipal("api-client"))
        }
    }
}

Bearer Token

Example of extracting a Bearer token with a custom Principal implementation:

@Module
public interface BearerAuthModule {

    default HttpServerPrincipalExtractor<UserPrincipal> bearerExtractor(TokenValidator validator) {
        return (request, value) -> {
            if (value == null || !value.startsWith("Bearer ")) {
                return CompletableFuture.failedFuture(
                    new IllegalAccessException("No Bearer token")
                );
            }

            String token = value.substring(7);
            return validator.validate(token)
                .thenApply(userData -> new UserPrincipal(userData.userId(), userData.roles()));
        };
    }
}
@Module
interface BearerAuthModule {

    fun bearerExtractor(validator: TokenValidator): HttpServerPrincipalExtractor<UserPrincipal> {
        return HttpServerPrincipalExtractor { request, value ->
            if (value == null || !value.startsWith("Bearer ")) {
                return CompletableFuture.failedFuture(
                    IllegalAccessException("No Bearer token")
                )
            }

            val token = value.substring(7)
            validator.validate(token)
                .thenApply { userData ->
                    UserPrincipal(userData.userId, userData.roles)
                }
        }
    }
}

Getting Principal

The current authorization context can be obtained anywhere during request processing:

@Component
@HttpController
public class SecureController {

    @HttpRoute(method = HttpMethod.GET, path = "/secure")
    public String getSecureData() {
        Principal principal = Principal.current();
        if (principal instanceof UserPrincipal user) {
            return "Hello, user: " + user.userId();
        }
        throw new SecurityException("Not authenticated");
    }
}
@Component
@HttpController
class SecureController {

    @HttpRoute(method = HttpMethod.GET, path = "/secure")
    fun getSecureData(): String {
        val principal = Principal.current()
        return if (principal is UserPrincipal) {
            "Hello, user: ${principal.userId}"
        } else {
            throw SecurityException("Not authenticated")
        }
    }
}

OAuth2

For OAuth2 authorization, create an HttpServerPrincipalExtractor that validates the token via an OAuth2 provider:

@Module
public interface OAuth2Module {

    default HttpServerPrincipalExtractor<ScopedUser> oauth2Extractor(OAuth2Client oauth2Client) {
        return (request, value) -> {
            if (value == null || !value.startsWith("Bearer ")) {
                return CompletableFuture.failedFuture(
                    new IllegalAccessException("No OAuth2 token")
                );
            }

            String token = value.substring(7);
            return oauth2Client.introspect(token)
                .thenApply(introspection -> 
                    new ScopedUser(
                        introspection.subject(),
                        introspection.scopes()
                    )
                );
        };
    }
}
@Module
interface OAuth2Module {

    fun oauth2Extractor(oauth2Client: OAuth2Client): HttpServerPrincipalExtractor<ScopedUser> {
        return HttpServerPrincipalExtractor { request, value ->
            if (value == null || !value.startsWith("Bearer ")) {
                return CompletableFuture.failedFuture(
                    IllegalAccessException("No OAuth2 token")
                )
            }

            val token = value.substring(7)
            oauth2Client.introspect(token)
                .thenApply { introspection ->
                    ScopedUser(introspection.subject, introspection.scopes)
                }
        }
    }
}

Scope Checking

To check scopes, create an interceptor that validates PrincipalWithScopes:

@Component
public final class ScopeCheckingInterceptor implements HttpServerInterceptor {

    private final String requiredScope;

    public ScopeCheckingInterceptor(@ConfigSource("auth.requiredScope") String requiredScope) {
        this.requiredScope = requiredScope;
    }

    @Override
    public CompletionStage<HttpServerResponse> intercept(Context context, 
                                                         HttpServerRequest request, 
                                                         InterceptChain chain) {
        Principal principal = Principal.current(context);
        if (principal instanceof PrincipalWithScopes scoped) {
            if (!scoped.scopes().contains(requiredScope)) {
                return CompletableFuture.failedFuture(
                    HttpServerResponseException.of(403, "Insufficient scope")
                );
            }
        } else {
            return CompletableFuture.failedFuture(
                HttpServerResponseException.of(403, "No scopes available")
            );
        }

        return chain.process(context, request);
    }
}
@Component
class ScopeCheckingInterceptor(
    @ConfigSource("auth.requiredScope") private val requiredScope: String
) : HttpServerInterceptor {

    override fun intercept(
        context: Context,
        request: HttpServerRequest,
        chain: HttpServerInterceptor.InterceptChain
    ): CompletionStage<HttpServerResponse> {
        val principal = Principal.current(context)
        if (principal is PrincipalWithScopes) {
            if (!principal.scopes.contains(requiredScope)) {
                return CompletableFuture.failedFuture(
                    HttpServerResponseException.of(403, "Insufficient scope")
                )
            }
        } else {
            return CompletableFuture.failedFuture(
                HttpServerResponseException.of(403, "No scopes available")
            )
        }

        return chain.process(context, request)
    }
}

OpenAPI Integration

When using Kora OpenAPI Generator, authorization is configured automatically based on the OpenAPI specification. The generator creates:

  1. ApiSecurity interface with marker classes for each authorization type
  2. HttpServerInterceptor for each security scheme
  3. Requires providing an HttpServerPrincipalExtractor with the corresponding @Tag

Example from kora-examples:

@KoraApp
public interface Application extends
        HoconConfigModule,
        UndertowHttpServerModule,
        JsonModule {

    @Tag(ApiSecurity.ApiKeyAuth.class)
    default HttpServerPrincipalExtractor<Principal> apiKeyExtractor(DataApiAuthConfig config) {
        return (request, value) -> {
            if (value == null || !config.value().equals(value)) {
                throw new SecurityException("Invalid API key");
            }
            return CompletableFuture.completedFuture(
                new DataApiPrincipal("data-api-client")
            );
        };
    }
}

where DataApiPrincipal:

public record DataApiPrincipal(String name) implements Principal {}
@KoraApp
interface Application :
    HoconConfigModule,
    UndertowHttpServerModule,
    JsonModule {

    @Tag(ApiSecurity.ApiKeyAuth::class)
    fun apiKeyExtractor(config: DataApiAuthConfig): HttpServerPrincipalExtractor<Principal> {
        return HttpServerPrincipalExtractor { request, value ->
            if (value == null || config.value() != value) {
                throw SecurityException("Invalid API key")
            }
            CompletableFuture.completedFuture(
                DataApiPrincipal("data-api-client")
            )
        }
    }
}

where DataApiPrincipal:

data class DataApiPrincipal(val name: String) : Principal

Configuration:

auth.apiKey {
  value = "secret-api-key-123"
}

Error Handling

If HttpServerPrincipalExtractor throws an exception or returns null, the request is rejected with 403 Forbidden. For custom authorization error handling, use an interceptor:

@Tag(HttpServerModule.class)
@Component
public final class AuthErrorInterceptor implements HttpServerInterceptor {

    @Override
    public CompletionStage<HttpServerResponse> intercept(Context context, 
                                                         HttpServerRequest request, 
                                                         InterceptChain chain) {
        return chain.process(context, request).exceptionally(e -> {
            if (e instanceof CompletionException) {
                e = e.getCause();
            }
            if (e instanceof IllegalAccessException) {
                return HttpServerResponse.of(401, HttpBody.plaintext("Unauthorized: " + e.getMessage()));
            }
            if (e instanceof SecurityException) {
                return HttpServerResponse.of(403, HttpBody.plaintext("Forbidden: " + e.getMessage()));
            }
            throw new CompletionException(e);
        });
    }
}
@Tag(HttpServerModule::class)
@Component
class AuthErrorInterceptor : HttpServerInterceptor {

    override fun intercept(
        context: Context,
        request: HttpServerRequest,
        chain: HttpServerInterceptor.InterceptChain
    ): CompletionStage<HttpServerResponse> {
        return chain.process(context, request).exceptionally { e ->
            val error = if (e is CompletionException) e.cause!! else e
            when (error) {
                is IllegalAccessException -> 
                    HttpServerResponse.of(401, HttpBody.plaintext("Unauthorized: ${error.message}"))
                is SecurityException -> 
                    HttpServerResponse.of(403, HttpBody.plaintext("Forbidden: ${error.message}"))
                else -> throw CompletionException(error)
            }
        }
    }
}

Telemetry

HTTP Server uses a telemetry contract for logging, metrics, and tracing of requests. Telemetry configuration (section telemetry { logging / metrics / tracing }) is described in the Configuration section. Extension points are located in ru.tinkoff.kora.http.server.common.telemetry.

For each HTTP request, an HttpServerTelemetry.HttpServerTelemetryContext is created, which is closed upon request completion. The request is described through telemetry handler parameters, including method, path, response status, and duration.

The default factory DefaultHttpServerTelemetryFactory combines three factories: - HttpServerLoggerFactory builds HttpServerLogger for logging request start/end; - HttpServerMetricsFactory builds HttpServerMetrics for writing request metrics; - HttpServerTracerFactory builds HttpServerTracer for distributed tracing.

Metrics and tracing are described in the Metrics Reference section.