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

Перейти к содержанию

HTTP сервер

Модуль HTTP-сервера описывает входящую HTTP-границу приложения: прием запроса, разбор параметров, чтение тела, выбор обработчика, формирование ответа, телеметрию и перехватчики. В Kora можно описывать контроллеры декларативно через @HttpController и @HttpRoute как тонкий слой абстракции, либо регистрировать обработчики императивно через HttpServerRequestHandler.

Декларативный подход подходит для большинства API: сигнатура метода описывает HTTP-контракт, а Kora во время компиляции создает обработчик. Императивный подход полезен для низкоуровневых или динамических маршрутов, где запрос удобнее обрабатывать вручную.

Совет

Мы советуем использовать подход, при котором первичен контракт в формате OpenAPI, а контроллеры создаются с помощью генератора. Такой подход помогает сохранить согласованность контракта между потребителем и владельцем контракта и позволяет использовать тот же контракт для генерации клиентов. Подробнее про генератор смотрите в разделе про генерацию из OpenAPI.

Если нужен пошаговый разбор перед справочным описанием, смотрите HTTP-сервер и продвинутый HTTP-сервер.

Подключение

Реализация основана на Undertow. Undertow — это легковесный веб-сервер с открытым исходным кодом для Java-приложений. Он построен на асинхронных и неблокирующих операциях ввода-вывода с использованием NIO, что обеспечивает высокую производительность и низкое потребление ресурсов.

Зависимость build.gradle:

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

Модуль:

@KoraApp
public interface Application extends UndertowHttpServerModule { }

Зависимость build.gradle.kts:

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

Модуль:

@KoraApp
interface Application : UndertowHttpServerModule

Конфигурация

Основные параметры конфигурации HTTP-сервера:

httpServer {
    publicApiHttpPort = 8080 //(1)!
    privateApiHttpPort = 8085 //(2)!
    virtualThreadsEnabled = false //(3)!
    maxRequestBodySize = "256MiB" //(4)!
}
  1. Порт публичного HTTP-сервера (по умолчанию: 8080)
  2. Порт служебного HTTP-сервера (по умолчанию: 8085)
  3. Включает виртуальные потоки для блокирующей обработки запросов вместо пула blockingThreads, требует Java 21+ (по умолчанию: false)
  4. Максимально допустимый размер тела входящего запроса (по умолчанию: 256MiB)
httpServer:
  publicApiHttpPort: 8080 #(1)!
  privateApiHttpPort: 8085 #(2)!
  virtualThreadsEnabled: false #(3)!
  maxRequestBodySize: "256MiB" #(4)!
  1. Порт публичного HTTP-сервера (по умолчанию: 8080)
  2. Порт служебного HTTP-сервера (по умолчанию: 8085)
  3. Включает виртуальные потоки для блокирующей обработки запросов вместо пула blockingThreads, требует Java 21+ (по умолчанию: false)
  4. Максимально допустимый размер тела входящего запроса (по умолчанию: 256MiB)
Полная конфигурация

Пример полной конфигурации, описанной в классе HttpServerConfig (указаны примеры значений или значения по умолчанию):

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. Порт публичного HTTP-сервера (по умолчанию: 8080)
  2. Порт служебного HTTP-сервера (по умолчанию: 8085)
  3. Путь для получения метрик на служебном сервере (по умолчанию: /metrics)
  4. Путь для получения статуса проб готовности на служебном сервере (по умолчанию: /system/readiness)
  5. Путь для получения статуса проб жизнеспособности на служебном сервере (по умолчанию: /system/liveness)
  6. Игнорировать ли завершающий / в пути: если включено, /my/path и /my/path/ будут считаться одним маршрутом (по умолчанию: false)
  7. Количество потоков сетевого ввода-вывода (по умолчанию: количество доступных процессоров, но не меньше 2)
  8. Количество потоков для блокирующей обработки запросов (по умолчанию: min(max(доступные процессоры, 2) * 8, 200))
  9. Время ожидания обработки перед выключением сервера при штатном завершении (по умолчанию: 30s)
  10. Максимальное время жизни потока обработчика запроса без работы (по умолчанию: 60s)
  11. Максимальное время ожидания чтения данных из сокета или соединения; 0s отключает тайм-аут (по умолчанию: 0s)
  12. Максимальное время ожидания записи данных в сокет или соединение; 0s отключает тайм-аут (по умолчанию: 0s)
  13. Включать ли TCP keep-alive для сокета или соединения (по умолчанию: false)
  14. Включает виртуальные потоки для блокирующей обработки запросов вместо пула blockingThreads, требует Java 21+ (по умолчанию: false)
  15. Максимально допустимый размер тела входящего запроса (по умолчанию: 256MiB)
  16. Включает логирование модуля (по умолчанию: false)
  17. Включает логирование стека вызовов при исключении (по умолчанию: true)
  18. Маска, которая используется для скрытия указанных заголовков и параметров запроса или ответа (по умолчанию: ***)
  19. Список параметров запроса, которые следует скрывать (по умолчанию: [])
  20. Список заголовков запроса или ответа, которые следует скрывать (по умолчанию: [ "authorization", "cookie", "set-cookie" ])
  21. Использовать ли шаблон пути запроса при логировании; если не указано, шаблон используется всегда, кроме уровня TRACE, где используется полный путь (по умолчанию не указано, необязательно)
  22. Включает метрики модуля (по умолчанию: true)
  23. Настройка SLO для метрик (по умолчанию: ru.tinkoff.kora.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO)
  24. Настройка тегов для метрик (по умолчанию: {})
  25. Включает трассировку модуля (по умолчанию: true)
  26. Настройка атрибутов для трассировки (по умолчанию: {})
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. Порт публичного HTTP-сервера (по умолчанию: 8080)
  2. Порт служебного HTTP-сервера (по умолчанию: 8085)
  3. Путь для получения метрик на служебном сервере (по умолчанию: /metrics)
  4. Путь для получения статуса проб готовности на служебном сервере (по умолчанию: /system/readiness)
  5. Путь для получения статуса проб жизнеспособности на служебном сервере (по умолчанию: /system/liveness)
  6. Игнорировать ли завершающий / в пути: если включено, /my/path и /my/path/ будут считаться одним маршрутом (по умолчанию: false)
  7. Количество потоков сетевого ввода-вывода (по умолчанию: количество доступных процессоров, но не меньше 2)
  8. Количество потоков для блокирующей обработки запросов (по умолчанию: min(max(доступные процессоры, 2) * 8, 200))
  9. Время ожидания обработки перед выключением сервера при штатном завершении (по умолчанию: 30s)
  10. Максимальное время жизни потока обработчика запроса без работы (по умолчанию: 60s)
  11. Максимальное время ожидания чтения данных из сокета или соединения; 0s отключает тайм-аут (по умолчанию: 0s)
  12. Максимальное время ожидания записи данных в сокет или соединение; 0s отключает тайм-аут (по умолчанию: 0s)
  13. Включать ли TCP keep-alive для сокета или соединения (по умолчанию: false)
  14. Включает виртуальные потоки для блокирующей обработки запросов вместо пула blockingThreads, требует Java 21+ (по умолчанию: false)
  15. Максимально допустимый размер тела входящего запроса (по умолчанию: 256MiB)
  16. Включает логирование модуля (по умолчанию: false)
  17. Включает логирование стека вызовов при исключении (по умолчанию: true)
  18. Маска, которая используется для скрытия указанных заголовков и параметров запроса или ответа (по умолчанию: ***)
  19. Список параметров запроса, которые следует скрывать (по умолчанию: [])
  20. Список заголовков запроса или ответа, которые следует скрывать (по умолчанию: [ "authorization", "cookie", "set-cookie" ])
  21. Использовать ли шаблон пути запроса при логировании; если не указано, шаблон используется всегда, кроме уровня TRACE, где используется полный путь (по умолчанию не указано, необязательно)
  22. Включает метрики модуля (по умолчанию: true)
  23. Настройка SLO для метрик (по умолчанию: ru.tinkoff.kora.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO)
  24. Настройка тегов для метрик (по умолчанию: {})
  25. Включает трассировку модуля (по умолчанию: true)
  26. Настройка атрибутов для трассировки (по умолчанию: {})

Предоставляемые метрики модуля описаны в разделе Справочник метрик.

Kora предоставляет тонкую настройку HTTP-сервера Undertow через два специализированных интерфейса конфигурации: UndertowConfigurer и HttpHandlerConfigurer.
Они позволяют настраивать поведение сервера и конвейер обработки запросов, не жертвуя интеграцией с модульной архитектурой Kora.

Контроллер декларативный

Для создания контроллера следует использовать @HttpController аннотацию, а для его регистрации как зависимость @Component. Аннотация @HttpRoute отвечает за указания пути и метода HTTP для конкретного метода обработчика.

@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. Указывает что класс является компонентом и его требуется зарегистрировать в контейнере приложения
  2. Указывает что класс является контроллером и содержит HTTP-обработчики
  3. Указывает что метод является обработчиком пути в контроллере
  4. Указывает тип HTTP-метода обработчика
  5. Указывает путь метода обработчика
@Component //(1)!
@HttpController //(2)!
class SomeController {

    //(3)!
    @HttpRoute(method = HttpMethod.POST,  //(4)!
               path = "/hello/world") //(5)!
    fun helloWorld(): String {
        return "Hello World"
    }
}
  1. Указывает что класс является компонентом и его требуется зарегистрировать в контейнере приложения
  2. Указывает что класс является контроллером и содержит HTTP-обработчики
  3. Указывает что метод является обработчиком пути в контроллере
  4. Указывает тип HTTP-метода обработчика
  5. Указывает путь метода обработчика

Запрос

Раздел описывает преобразование HTTP-запроса в аргументы метода контроллера. Для частей запроса используются специальные аннотации, а тело запроса передается аргументом без такой аннотации.

Преобразование параметров из строки

Значения из пути, параметров запроса, заголовков и cookie приходят как строки. Для преобразования строки в нужный тип Kora использует StringParameterReader<T>:

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

StringParameterReader<T> ищется как компонент графа по точному типу параметра. Если параметр объявлен как List<T> или Set<T>, преобразователь применяется к каждому значению отдельно.

Из коробки поддерживаются String, Boolean, Integer, Long, Float, Double, UUID, BigInteger, BigDecimal, Duration, LocalDate, LocalTime, LocalDateTime, OffsetTime, OffsetDateTime, ZonedDateTime и enum. Для enum по умолчанию используется имя значения через Enum.name(). Если значение невозможно преобразовать, запрос завершается ответом 400 через 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" }
        )
    }
}

После регистрации преобразователя пользовательский тип можно использовать в параметрах контроллера:

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

Параметр пути

@Path — обозначает значение части пути запроса, сам параметр указывается в {кавычках} в пути и имя параметра указывается в value либо по умолчанию равно имени аргумента метода. Значение преобразуется через StringParameterReader<T>, поэтому можно использовать как встроенные типы, так и пользовательские.

@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 — значение параметра запроса, имя параметра указывается в value либо по умолчанию равно имени аргумента метода. Поддерживаются одиночные значения, List<T> и Set<T>. Для List<T> сохраняются все значения параметра, для Set<T> повторяющиеся значения удаляются с сохранением порядка первого появления.

@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";
    }
}

Заголовок запроса

@Header — значение заголовка запроса, имя параметра указывается в value либо по умолчанию равно имени аргумента метода. Поддерживаются одиночные значения, List<T> и Set<T>. Для List<T> и Set<T> используются все значения заголовка.

@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";
    }
}

Тело запроса

Для указания тела запроса требуется использовать аргумент метода без специальных аннотаций. По умолчанию поддерживаются byte[], ByteBuffer, String, FormUrlEncoded, FormMultipart и пользовательские типы через HttpServerRequestMapper<T>.

JSON

Для указания, что тело является JSON и для него требуется автоматически создать и внедрить JsonReader<T>, используется аннотация @Json:

@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. Указывает что тело должно быть прочитано как 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. Указывает что тело должно быть прочитано как JSON

Требуется подключить модуль JSON.

Текстовая форма

Можно использовать FormUrlEncoded как тип аргумента тела форма данных.

@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"
    }
}
Бинарная форма

Можно использовать FormMultipart как тип аргумента тела бинарная форма.

@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 либо по умолчанию равно имени аргумента метода. Можно получить значение как String, как тип Cookie с именем, значением и атрибутами, либо как другой тип через 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";
    }
}

Пользовательский параметр

Если требуется собрать аргумент метода из запроса вручную, можно использовать специальный интерфейс HttpServerRequestMapper<T>. Такой подход удобен для пользовательского контекста, авторизации, сложной проверки заголовков или нескольких частей запроса сразу:

@Component
@HttpController
public final class SomeController {

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

    public static final class RequestMapper implements HttpServerRequestMapper<UserContext> {

        @Override
        public UserContext apply(HttpServerRequest request) {
            return new UserContext(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) UserContext context) {
        return "Hello World";
    }
}
@Component
@HttpController
class MapperRequestController {

    data class UserContext(val userId: String, val traceId: String)

    class RequestMapper : HttpServerRequestMapper<UserContext> {
        override fun apply(request: HttpServerRequest): UserContext {
            return UserContext(
                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: UserContext): String {
        return "Hello World"
    }
}

Обязательные параметры

По умолчанию все аргументы, объявленные в методе, являются обязательными. Если обязательное значение отсутствует в запросе, Kora вернет ответ 400.

По умолчанию все аргументы метода, которые не используют синтаксис Kotlin Nullability, считаются обязательными. Если обязательное значение отсутствует в запросе, Kora вернет ответ 400.

Необязательные параметры

Если аргумент метода является необязательным, то есть может отсутствовать в запросе, можно использовать аннотацию @Nullable или Optional<T> для одиночных значений:

@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. Подойдет любая аннотация @Nullable, например javax.annotation.Nullable, jakarta.annotation.Nullable или org.jetbrains.annotations.Nullable.

Предполагается использовать синтаксис Kotlin Nullability и помечать такой параметр как необязательный:

@Component
@HttpController
class SomeController {

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

Ответ

По умолчанию можно использовать стандартные типы возвращаемых значений: byte[], ByteBuffer, String. Они будут обработаны со статусом 200 и соответствующим заголовком типа ответа.

Если нужно вручную указать статус, заголовки или тело, метод может вернуть HttpServerResponse. Основной контракт HttpServerResponse состоит из кода ответа, заголовков и необязательного тела:

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-ответа
  2. Заголовки ответа
  3. Тело ответа
@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-ответа
  2. Заголовки ответа
  3. Тело ответа

JSON

Если предполагается отвечать в формате JSON, требуется использовать аннотацию @Json над методом. Для типа ответа Kora найдет или создаст JsonWriter<T>:

@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. Указывает что ответ должен быть в формате JSON
@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. Указывает что ответ должен быть в формате JSON

Требуется подключить модуль JSON.

Сущность ответа

Если требуется вернуть тело, заголовки и код состояния ответа вместе, используется HttpResponseEntity<T> — обертка над телом ответа.

Ниже показан пример, аналогичный примеру JSON, вместе с оберткой HttpResponseEntity:

@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"));
    }
}

Ответ исключение

Если требуется прервать обработку и сразу вернуть ошибку, можно бросить HttpServerResponseException. Это одновременно исключение и HttpServerResponse, поэтому его можно выбросить из контроллера, сервиса или преобразователя параметра.

Фабричные методы HttpServerResponseException.of(...) позволяют указать код состояния, текст ответа, причину и заголовки. Тело ответа будет записано как 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"
    }
}

Пользовательский ответ

Если требуется сформировать ответ нестандартным способом, можно использовать специальный интерфейс HttpServerResponseMapper<T>. Он получает Context, исходный HttpServerRequest и результат метода контроллера, а возвращает готовый 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")
    }
}

Сигнатуры

Доступные сигнатуры для методов декларативного HTTP обработчика из коробки:

Под T подразумевается тип возвращаемого значения, либо Void.

Под T подразумевается тип возвращаемого значения, либо Unit.

Перехватчики

Можно создавать перехватчики для изменения поведения или добавления общей логики вокруг обработки запроса. Для этого используется интерфейс HttpServerInterceptor:

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;
    }
}

Перехватчик получает текущий Context, HttpServerRequest и цепочку дальнейшей обработки. Чтобы передать запрос дальше, нужно вызвать chain.process(context, request). Если перехватчик возвращает ответ сам, обработчик контроллера дальше не вызывается.

Перехватчики можно использовать на:

  • Конкретных методах контроллера
  • Контроллере целиком
  • Всех контроллерах сразу: для этого компонент перехватчика должен быть зарегистрирован с тегом @Tag(HttpServerModule.class); таких глобальных перехватчиков может быть несколько
@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"
    }
}

Обработка ошибок

Обработка ошибок на уровне всех HTTP-ответов может быть реализована через перехватчик. Ниже представлен простой пример такого перехватчика.

@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)
            }
        }
    }
}

Контроллер императивный

Для создания контроллера следует реализовать HttpServerRequestHandler.HandlerFunction интерфейс, а затем зарегистрировать его в обработчике HttpServerRequestHandler.

Ниже показан пример по обработке всех описанных декларативных параметров запроса из примеров выше:

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. Указывает тип HTTP-метода обработчика
  2. Указывает путь метода обработчика
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. Указывает тип HTTP-метода обработчика
  2. Указывает путь метода обработчика

Авторизация

Kora предоставляет механизм извлечения контекста авторизации из HTTP запроса через интерфейс HttpServerPrincipalExtractor. Этот интерфейс позволяет реализовать любую схему аутентификации: Basic/ApiKey/Bearer/OAuth.

Принцип работы

HttpServerPrincipalExtractor<T> извлекает токен из запроса (обычно из заголовка Authorization) и возвращает объект Principal. Полученный Principal сохраняется в Context запроса и может быть получен в любом месте обработки запроса через Principal.current().

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

Где:

  • request — текущий HTTP запрос, из которого можно извлечь дополнительные данные (заголовки, параметры)
  • value — значение токена, извлеченное из заголовка Authorization (или другого источника)
  • T extends Principal — тип контекста авторизации, который будет сохранен в Context

Пользовательский Principal

Пользователь может создать простой принципал для API при необходимости, с полями или без них:

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

Для передачи дополнительной информации об авторизации (userId, роли, scope) создайте собственную реализацию Principal:

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

Если требуется работа с scope (областями видимости), используйте интерфейс PrincipalWithScopes:

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

Базовый пример

Простой пример извлечения API ключа из заголовка Authorization:

@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 токен

Пример извлечения Bearer токена с пользовательской реализацией Principal:

@Module
public interface BearerAuthModule {

    default HttpServerPrincipalExtractor<UserContext> 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 UserContext(userData.userId(), userData.roles()));
        };
    }
}
@Module
interface BearerAuthModule {

    fun bearerExtractor(validator: TokenValidator): HttpServerPrincipalExtractor<UserContext> {
        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 ->
                    UserContext(userData.userId, userData.roles)
                }
        }
    }
}

Получение Principal

Получить текущий контекст авторизации можно в любом месте обработки запроса:

@Component
@HttpController
public class SecureController {

    @HttpRoute(method = HttpMethod.GET, path = "/secure")
    public String getSecureData() {
        Principal principal = Principal.current();
        if (principal instanceof UserContext 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 UserContext) {
            "Hello, user: ${principal.userId}"
        } else {
            throw SecurityException("Not authenticated")
        }
    }
}

OAuth2

Для OAuth2 авторизации создайте HttpServerPrincipalExtractor, который проверяет токен через 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

Для проверки scope можно создать перехватчик, который проверяет 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 интеграция

При использовании Kora OpenAPI Generator авторизация настраивается автоматически на основе спецификации OpenAPI. Генератор создает:

  1. Интерфейс ApiSecurity с классами-маркерами для каждого типа авторизации
  2. HttpServerInterceptor для каждого security scheme
  3. Требует предоставить HttpServerPrincipalExtractor с соответствующим @Tag

Пример из 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")
            );
        };
    }
}

где 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")
            )
        }
    }
}

где DataApiPrincipal:

data class DataApiPrincipal(val name: String) : Principal

Конфигурация:

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

Обработка ошибок

Если HttpServerPrincipalExtractor выбрасывает исключение или возвращает null, запрос отклоняется с кодом 403 Forbidden. Для кастомной обработки ошибок авторизации используйте перехватчик:

@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)
            }
        }
    }
}

Телеметрия

HTTP Server использует контракт телеметрии для логирования, метрик и трассировки запросов. Конфигурация телеметрии (секция telemetry { logging / metrics / tracing }) описана в разделе Конфигурация. Точки расширения находятся в ru.tinkoff.kora.http.server.common.telemetry.

Для каждого HTTP-запроса создаётся HttpServerTelemetry.HttpServerTelemetryContext, который закрывается по завершении обработки запроса. Запрос описывается через параметры обработчика телеметрии, включая метод, путь, статус ответа и длительность.

Фабрика по умолчанию DefaultHttpServerTelemetryFactory объединяет три фабрики: - HttpServerLoggerFactory строит HttpServerLogger для логирования начала/конца обработки запроса; - HttpServerMetricsFactory строит HttpServerMetrics для записи метрик запросов; - HttpServerTracerFactory строит HttpServerTracer для распределённой трассировки.

Метрики и трассировка описаны в разделе Справочник метрик.