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

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

HTTP сервер

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

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

Обработка запросов синхронная. Каждый запрос выполняется на виртуальном потоке, поэтому обработчик может блокироваться: методы контроллеров, перехватчики и мапперы возвращают результат напрямую и никогда не возвращают CompletionStage, Mono/Flux или suspend-функцию.

Совет

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

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

Подключение

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

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

implementation "io.koraframework:http-server-undertow"

Модуль:

@KoraApp
public interface Application extends UndertowPublicHttpServerModule { }

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

implementation("io.koraframework:http-server-undertow")

Модуль:

@KoraApp
interface Application : UndertowPublicHttpServerModule

UndertowPublicHttpServerModule поднимает два сервера: публичный для контроллеров приложения и системный для проб и метрик. Если приложению нужен только системный сервер, подключите UndertowSystemHttpServerModule.

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

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

httpServer {
    port = 8080 //(1)!
    system.port = 8085 //(2)!
    maxRequestBodySize = "256MiB" //(3)!
    telemetry.logging.enabled = false //(4)!
}
  1. Порт публичного HTTP сервера (по умолчанию: 8080)
  2. Порт системного HTTP сервера (по умолчанию: 8085)
  3. Максимально допустимый размер тела входящего запроса (по умолчанию: 256MiB)
  4. Включает логирование запросов и ответов (по умолчанию: false)
httpServer:
  port: 8080 #(1)!
  system:
    port: 8085 #(2)!
  maxRequestBodySize: "256MiB" #(3)!
  telemetry:
    logging:
      enabled: false #(4)!
  1. Порт публичного HTTP сервера (по умолчанию: 8080)
  2. Порт системного HTTP сервера (по умолчанию: 8085)
  3. Максимально допустимый размер тела входящего запроса (по умолчанию: 256MiB)
  4. Включает логирование запросов и ответов (по умолчанию: false)
Полная конфигурация

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

httpServer {
    port = 8080 //(1)!
    ignoreTrailingSlash = false //(2)!
    shutdownWait = "30s" //(3)!
    socketReadTimeout = "0s" //(4)!
    socketWriteTimeout = "0s" //(5)!
    socketKeepAliveEnabled = false //(6)!
    headerKeepAliveEnabled = false //(7)!
    headerServerDateEnabled = true //(8)!
    maxRequestBodySize = "256MiB" //(9)!
    telemetry {
        logging {
            enabled = false //(10)!
            stacktrace = true //(11)!
            mask = "***" //(12)!
            maskQueries = [ ] //(13)!
            maskHeaders = [ "authorization", "cookie", "set-cookie" ] //(14)!
            pathFull = false //(15)!
            maxRequestBodyLogSize = "2MiB" //(16)!
            maxResponseBodyLogSize = "2MiB" //(17)!
        }
        metrics {
            enabled = false //(18)!
            slo = [ 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000 ] //(19)!
            tags = { // (20)!
                "key1" = "value1"
                "key2" = "value2"
            }
        }
        tracing {
            enabled = true //(21)!
            tracePathFull = true //(22)!
            attributes = { // (23)!
                "key1" = "value1"
                "key2" = "value2"
            }
        }
    }
}
  1. Порт публичного HTTP сервера (по умолчанию: 8080)
  2. Игнорировать ли завершающий / в пути: при включении /my/path и /my/path/ считаются одним маршрутом (по умолчанию: false)
  3. Время ожидания обработки запросов перед остановкой сервера при graceful shutdown (по умолчанию: 30s)
  4. Максимальное время ожидания чтения данных из сокета или соединения, 0s отключает таймаут (по умолчанию: 0s)
  5. Максимальное время ожидания записи данных в сокет или соединение, 0s отключает таймаут (по умолчанию: 0s)
  6. Включать ли TCP keep-alive для сокета или соединения (по умолчанию: false)
  7. Всегда ли отправлять заголовок ответа Connection: keep-alive (по умолчанию: false)
  8. Всегда ли отправлять заголовок ответа Date (по умолчанию: true)
  9. Максимально допустимый размер тела входящего запроса (по умолчанию: 256MiB)
  10. Включает логирование модуля (по умолчанию: false)
  11. Включает логирование стектрейса при исключении (по умолчанию: true)
  12. Маска, которой скрываются указанные заголовки и параметры запроса или ответа (по умолчанию: ***)
  13. Список параметров запроса, которые надо скрыть (по умолчанию: [])
  14. Список заголовков запроса или ответа, которые надо скрыть (по умолчанию: [ "authorization", "cookie", "set-cookie" ])
  15. Логировать ли полный путь запроса вместо шаблона маршрута; если не указано, используется шаблон, а на уровне TRACE — полный путь (по умолчанию не указано, опционально)
  16. Максимальный размер тела запроса, который может быть записан в лог; тело большего размера логируется без содержимого (по умолчанию: 2MiB)
  17. Максимальный размер тела ответа, который может быть записан в лог; тело большего размера логируется без содержимого (по умолчанию: 2MiB)
  18. Включает метрики модуля (по умолчанию: false)
  19. Настраивает SLO для метрик (по умолчанию: io.koraframework.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO)
  20. Настраивает теги метрик (по умолчанию: {})
  21. Включает трассировку модуля (по умолчанию: true)
  22. Записывать ли полный путь запроса в атрибут спана url.path (по умолчанию: true)
  23. Настраивает атрибуты трассировки (по умолчанию: {})
httpServer:
  port: 8080 #(1)!
  ignoreTrailingSlash: false #(2)!
  shutdownWait: "30s" #(3)!
  socketReadTimeout: "0s" #(4)!
  socketWriteTimeout: "0s" #(5)!
  socketKeepAliveEnabled: false #(6)!
  headerKeepAliveEnabled: false #(7)!
  headerServerDateEnabled: true #(8)!
  maxRequestBodySize: "256MiB" #(9)!
  telemetry:
    logging:
      enabled: false #(10)!
      stacktrace: true #(11)!
      mask: "***" #(12)!
      maskQueries: [ ] #(13)!
      maskHeaders: [ "authorization", "cookie", "set-cookie" ] #(14)!
      pathFull: false #(15)!
      maxRequestBodyLogSize: "2MiB" #(16)!
      maxResponseBodyLogSize: "2MiB" #(17)!
    metrics:
      enabled: false #(18)!
      slo: [ 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000 ] #(19)!
      tags: #(20)!
        key1: value1
        key2: value2
    tracing:
      enabled: true #(21)!
      tracePathFull: true #(22)!
      attributes: #(23)!
        key1: value1
        key2: value2
  1. Порт публичного HTTP сервера (по умолчанию: 8080)
  2. Игнорировать ли завершающий / в пути: при включении /my/path и /my/path/ считаются одним маршрутом (по умолчанию: false)
  3. Время ожидания обработки запросов перед остановкой сервера при graceful shutdown (по умолчанию: 30s)
  4. Максимальное время ожидания чтения данных из сокета или соединения, 0s отключает таймаут (по умолчанию: 0s)
  5. Максимальное время ожидания записи данных в сокет или соединение, 0s отключает таймаут (по умолчанию: 0s)
  6. Включать ли TCP keep-alive для сокета или соединения (по умолчанию: false)
  7. Всегда ли отправлять заголовок ответа Connection: keep-alive (по умолчанию: false)
  8. Всегда ли отправлять заголовок ответа Date (по умолчанию: true)
  9. Максимально допустимый размер тела входящего запроса (по умолчанию: 256MiB)
  10. Включает логирование модуля (по умолчанию: false)
  11. Включает логирование стектрейса при исключении (по умолчанию: true)
  12. Маска, которой скрываются указанные заголовки и параметры запроса или ответа (по умолчанию: ***)
  13. Список параметров запроса, которые надо скрыть (по умолчанию: [])
  14. Список заголовков запроса или ответа, которые надо скрыть (по умолчанию: [ "authorization", "cookie", "set-cookie" ])
  15. Логировать ли полный путь запроса вместо шаблона маршрута; если не указано, используется шаблон, а на уровне TRACE — полный путь (по умолчанию не указано, опционально)
  16. Максимальный размер тела запроса, который может быть записан в лог; тело большего размера логируется без содержимого (по умолчанию: 2MiB)
  17. Максимальный размер тела ответа, который может быть записан в лог; тело большего размера логируется без содержимого (по умолчанию: 2MiB)
  18. Включает метрики модуля (по умолчанию: false)
  19. Настраивает SLO для метрик (по умолчанию: io.koraframework.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO)
  20. Настраивает теги метрик (по умолчанию: {})
  21. Включает трассировку модуля (по умолчанию: true)
  22. Записывать ли полный путь запроса в атрибут спана url.path (по умолчанию: true)
  23. Настраивает атрибуты трассировки (по умолчанию: {})

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

Системный сервер

Системный сервер настраивается в собственной секции httpServer.system. SystemHttpServerConfig наследует HttpServerConfig, поэтому там доступны все перечисленные выше опции, плюс пути системных эндпоинтов:

httpServer.system {
    port = 8085 //(1)!
    metricsPath = "/metrics" //(2)!
    readinessPath = "/system/readiness" //(3)!
    livenessPath = "/system/liveness" //(4)!
    telemetry.tracing.enabled = false //(5)!
}
  1. Порт системного HTTP сервера (по умолчанию: 8085)
  2. Путь для получения метрик на системном сервере (по умолчанию: /metrics)
  3. Путь для получения статуса readiness пробы на системном сервере (по умолчанию: /system/readiness)
  4. Путь для получения статуса liveness пробы на системном сервере (по умолчанию: /system/liveness)
  5. Включает трассировку запросов системного сервера (по умолчанию: false, в отличие от публичного сервера)
httpServer:
  system:
    port: 8085 #(1)!
    metricsPath: "/metrics" #(2)!
    readinessPath: "/system/readiness" #(3)!
    livenessPath: "/system/liveness" #(4)!
    telemetry:
      tracing:
        enabled: false #(5)!
  1. Порт системного HTTP сервера (по умолчанию: 8085)
  2. Путь для получения метрик на системном сервере (по умолчанию: /metrics)
  3. Путь для получения статуса readiness пробы на системном сервере (по умолчанию: /system/readiness)
  4. Путь для получения статуса liveness пробы на системном сервере (по умолчанию: /system/liveness)
  5. Включает трассировку запросов системного сервера (по умолчанию: false, в отличие от публичного сервера)

Undertow

Транспортные настройки самого Undertow вынесены в отдельную секцию httpServer.undertow и общие для обоих серверов, поскольку настраивают единый XnioWorker:

httpServer.undertow {
    ioThreads = 4 //(1)!
    threadKeepAliveTimeout = "60s" //(2)!
}
  1. Количество потоков сетевого ввода-вывода (по умолчанию: количество доступных процессоров, но не меньше 2)
  2. Максимальное время простоя рабочего потока (по умолчанию: 60s)
httpServer:
  undertow:
    ioThreads: 4 #(1)!
    threadKeepAliveTimeout: "60s" #(2)!
  1. Количество потоков сетевого ввода-вывода (по умолчанию: количество доступных процессоров, но не меньше 2)
  2. Максимальное время простоя рабочего потока (по умолчанию: 60s)

Сама обработка запроса не использует ограниченный пул блокирующих потоков: каждое соединение обслуживается виртуальным потоком, поэтому опций blockingThreads и virtualThreadsEnabled больше нет.

Для всего, что не вынесено в конфигурацию, Kora предоставляет точки расширения Configurer<T>. Configurer<T> получает собираемый объект и возвращает тот, который будет использован:

@KoraApp
public interface Application extends UndertowPublicHttpServerModule {

    default Configurer<Undertow.Builder> undertowConfigurer() { //(1)!
        return builder -> builder.setServerOption(UndertowOptions.ENABLE_HTTP2, true);
    }

    default Configurer<HttpHandler> handlerConfigurer() { //(2)!
        return handler -> new RequestDumpingHandler(handler);
    }

    default Configurer<XnioWorker.Builder> workerConfigurer() { //(3)!
        return builder -> builder.setWorkerName("my-worker");
    }
}
  1. Настраивает билдер Undertow публичного сервера до его старта
  2. Оборачивает корневой HttpHandler публичного сервера
  3. Настраивает XnioWorker, общий для обоих серверов
@KoraApp
interface Application : UndertowPublicHttpServerModule {

    fun undertowConfigurer(): Configurer<Undertow.Builder> = //(1)!
        Configurer { builder -> builder.setServerOption(UndertowOptions.ENABLE_HTTP2, true) }

    fun handlerConfigurer(): Configurer<HttpHandler> = //(2)!
        Configurer { handler -> RequestDumpingHandler(handler) }

    fun workerConfigurer(): Configurer<XnioWorker.Builder> = //(3)!
        Configurer { builder -> builder.setWorkerName("my-worker") }
}
  1. Настраивает билдер Undertow публичного сервера до его старта
  2. Оборачивает корневой HttpHandler публичного сервера
  3. Настраивает XnioWorker, общий для обоих серверов

Configurer<Undertow.Builder> или Configurer<HttpHandler> без тега применяется к публичному серверу. Чтобы настроить системный сервер, пометьте компонент тегом @SystemApi.

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

Для создания контроллера используется аннотация @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. Указывает путь метода-обработчика

HttpRoute.method() имеет тип String, а HttpMethod — это набор констант (GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE, PATCH, QUERY), поэтому нестандартный метод можно указать литералом: @HttpRoute(method = "PURGE", path = "/cache").

Маршрутизация

@HttpRoute сопоставляет запрос по его method (HTTP-метод из HttpMethod) и path. path — это шаблон, который должен начинаться с / и может содержать один или несколько сегментов {name} — каждый является параметром пути, связываемым через @Path:

  • /users — статический путь
  • /users/{id} — один параметр пути
  • /users/{userId}/orders/{orderId} — несколько параметров пути, в том числе в середине пути

Параметр пути всегда соответствует ровно одному сегменту пути; значение никогда не охватывает /.

Завершающий слеш. По умолчанию сопоставление точное, поэтому /users и /users/ — это разные маршруты: запрос к /users/ при маршруте /users вернёт 404. Чтобы считать их одним маршрутом, включите httpServer.ignoreTrailingSlash (см. Конфигурация):

httpServer {
    ignoreTrailingSlash = true
}
httpServer:
  ignoreTrailingSlash: true

Сопоставление метода. Путь, обслуживаемый методом без подходящего маршрута, вернёт 405 Method Not Allowed; неизвестный путь вернёт 404 Not Found. Сопоставленный шаблон доступен во время выполнения как HttpServerRequest.route() и используется как метка пути с низкой кардинальностью в метриках и трассировке (см. Телеметрия).

Запрос

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

Конвертация строковых параметров

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

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

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

String, Boolean, Integer, Long, Double и UUID разбираются самим сгенерированным обработчиком и конвертера не требуют. Из коробки Kora также предоставляет конвертеры для Float, 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 HttpServerParameterReader<UserId> userIdParameterReader() {
        return HttpServerParameterReader.of(
            value -> new UserId(Long.parseLong(value)),
            value -> "Invalid user id: " + value
        );
    }
}
data class UserId(val value: Long)

@Module
interface UserIdModule {

    fun userIdParameterReader(): HttpServerParameterReader<UserId> {
        return HttpServerParameterReader.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 — обозначает значение части пути запроса, сам параметр указывается в пути как {path}, а имя параметра задается в value либо по умолчанию равно имени аргумента метода. Значение преобразуется через HttpServerParameterReader<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";
    }
}

Параметр пути всегда обязателен: если указанное в @Path имя отсутствует в шаблоне маршрута, компиляция завершается ошибкой Path parameter '...' is not present in the request mapping path.

Параметр запроса

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

Параметр без значения (/hello/world?queryName) считается отсутствующим: обязательный параметр приведет к ответу 400 с сообщением Query parameter 'queryName' is required.

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

@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")
    fun helloWorld(
        @Header("headerName") headerValue: String,
        @Header("headerNameList") headerValues: List<String>
    ): String {
        return "Hello World";
    }
}

Тело запроса

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

InputStream и HttpBodyInput дают доступ к телу без буферизации его в памяти, что удобно для больших загрузок.

JSON

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

@Component
@HttpController
public final class SomeController {

    @Json
    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 {

    @Json
    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.

Form UrlEncoded

Объявите аргумент FormUrlEncoded (из ru.tinkoff.kora.http.common.form), чтобы принять запрос с типом содержимого application/x-www-form-urlencoded (форма данных). Аннотации @Json или @Mapping не требуются — для этого типа в Kora есть встроенный reader.

FormUrlEncoded итерируем и предоставляет:

  • get(String name) — возвращает FormUrlEncoded.FormPart с таким именем, либо null
  • FormPart.name() — имя поля
  • FormPart.values() — все значения поля (поле может повторяться в форме)
@Component
@HttpController
public final class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/form/encoded")
    public String handle(FormUrlEncoded body) {
        FormUrlEncoded.FormPart name = body.get("name"); //(1)!
        String firstName = (name != null) ? name.values().get(0) : null;
        return "Hello " + firstName;
    }
}
  1. Читает поле name из отправленной формы

  2. FormUrlEncoded.get(String) возвращает FormPart(String name, List<String> values) либо null

@Component
@HttpController
class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/form/encoded")
    fun handle(body: FormUrlEncoded): String {
        val name = body.get("name") //(1)!
        val firstName = name?.values()?.firstOrNull()
        return "Hello $firstName"
    }
}
  1. Читает поле name из отправленной формы

  2. FormUrlEncoded.get(String) возвращает FormPart(String name, List<String> values) либо null

Объявите аргумент FormMultipart (из ru.tinkoff.kora.http.common.form), чтобы принять запрос multipart/form-data (бинарная форма), обычно используется для загрузки файлов. Аннотации @Json или @Mapping не требуются.

FormMultipart.parts() возвращает список частей, где каждая FormMultipart.FormPart — один из sealed-подтипов:

  • MultipartData — текстовое поле: name(), content() (String)
  • MultipartFile — файл, загруженный в память: name(), fileName(), contentType(), content() (byte[])
  • MultipartFileStream — потоковый файл: name(), fileName(), contentType(), content() (Flow.Publisher<ByteBuffer>)
@Component
@HttpController
public final class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/form/multipart")
    public String handle(FormMultipart body) {
        for (FormMultipart.FormPart part : body.parts()) {
            if (part instanceof FormMultipart.FormPart.MultipartData data) { //(1)!
                String value = data.content();
            } else if (part instanceof FormMultipart.FormPart.MultipartFile file) { //(2)!
                String fileName = file.fileName();
                String contentType = file.contentType();
                byte[] content = file.content();
            }
        }
        for (var part : body.parts()) { //(1)!
            System.out.println(part.name());
        }
        return "OK";
    }
}
  1. Текстовое поле формы
  2. Файл, загруженный в форме

  3. FormMultipart.parts() возвращает запечатанный FormPart: MultipartData, MultipartFile либо MultipartFileStream

@Component
@HttpController
class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/form/multipart")
    fun handle(body: FormMultipart): String {
        for (part in body.parts()) {
            when (part) {
                is FormMultipart.FormPart.MultipartData -> { //(1)!
                    val value = part.content()
                }
                is FormMultipart.FormPart.MultipartFile -> { //(2)!
                    val fileName = part.fileName()
                    val contentType = part.contentType()
                    val content = part.content()
                }
                else -> {}
            }
        }
        return "OK"
    }
}
  1. FormMultipart.parts() возвращает запечатанный FormPart: MultipartData, MultipartFile либо MultipartFileStream

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

    @Component //(1)!
    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";
    }
}
  1. Сгенерированный модуль контроллера внедряет маппер как зависимость, поэтому класс маппера обязан быть компонентом графа
@Component
@HttpController
class SomeController {

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

    @Component //(1)!
    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")
    fun get(@Mapping(RequestMapper::class) context: UserContext): String {
        return "Hello World"
    }
}
  1. Сгенерированный модуль контроллера внедряет маппер как зависимость, поэтому класс маппера обязан быть компонентом графа
No component found for dependency

Класс маппера, указанный в @Mapping, никогда не создается сгенерированным модулем — он запрашивается из контейнера. Забытая аннотация @Component приводит к ошибке сборки графа:

No component found for dependency:
  SomeController.RequestMapper (no tags)

То же самое относится к классам перехватчиков из @InterceptWith.

Исключение, выброшенное маппером, превращается в ответ 400, если только оно само не является HttpServerResponse, поэтому маппер — еще и удобное место, чтобы отклонить некорректный запрос с точным статус-кодом.

Полный запрос

Метод контроллера может принимать сам HttpServerRequest, когда обработчику нужен исходный запрос:

@Component
@HttpController
public final class SomeController {

    @HttpRoute(method = HttpMethod.GET, path = "/request")
    public HttpServerResponse get(HttpServerRequest request) {
        var header = request.headers().getFirst("header"); //(1)!
        var query = request.queryParams().get("query"); //(2)!
        var path = request.pathParams().get("path"); //(3)!
        return HttpServerResponse.of(200, HttpBody.plaintext(request.path()));
    }
}
  1. HttpHeaders с методами getFirst и getAll
  2. Map<String, List<String>> параметров запроса
  3. Map<String, String> параметров пути, разобранных по шаблону маршрута
@Component
@HttpController
class SomeController {

    @HttpRoute(method = HttpMethod.GET, path = "/request")
    fun get(request: HttpServerRequest): HttpServerResponse {
        val header = request.headers().getFirst("header") //(1)!
        val query = request.queryParams()["query"] //(2)!
        val path = request.pathParams()["path"] //(3)!
        return HttpServerResponse.of(200, HttpBody.plaintext(request.path()))
    }
}
  1. HttpHeaders с методами getFirst и getAll
  2. Map<String, List<String>> параметров запроса
  3. Map<String, String> параметров пути, разобранных по шаблону маршрута

HttpServerRequest также предоставляет host(), scheme(), method(), path(), pathTemplate(), cookies() и body().

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

По умолчанию все объявленные в методе аргументы обязательны. Если обязательное значение отсутствует в запросе, 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. Kora и примеры используют org.jspecify.annotations.Nullable; подойдет любая аннотация с простым именем 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, HttpBodyOutput. Они обрабатываются со статусом 200 и соответствующим заголовком типа содержимого ответа. Метод с типом void также отвечает 200 с пустым телом.

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

public interface HttpServerResponse {
    int code();
    HttpHeaders 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. Тело ответа

HttpBody предоставляет фабричные методы empty(), plaintext(...), json(...), octetStream(...) и of(contentType, ...). Для потокового ответа используйте HttpBodyOutput.of(contentType, InputStream) либо HttpBodyOutput.of(contentType, os -> ...).

JSON

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

@Component
@HttpController
public final class SomeController {

    @Json
    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 {

    @Json
    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 {

    @Json
    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 {

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

Если переопределить нужно только статус-код, доступен HttpResponseEntity.of(code, body). Заголовок content-type, заданный в сущности, имеет приоритет над тем, который сформировал нижележащий маппер.

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

Если обработку нужно прервать и сразу вернуть ошибку, выбрасывается 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>. Он получает исходный HttpServerRequest и результат метода контроллера, а возвращает готовый HttpServerResponse:

@Component
@HttpController
public final class SomeController {

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

    @Component //(1)!
    public static final class ResponseMapper implements HttpServerResponseMapper<HelloWorldResponse> {

        @Override
        public HttpServerResponse apply(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");
    }
}
  1. Как и мапперы запроса, класс внедряется в сгенерированный модуль и обязан быть компонентом графа
@Component
@HttpController
class SomeController {

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

    @Component //(1)!
    class ResponseMapper : HttpServerResponseMapper<HelloWorldResponse> {

        override fun apply(request: HttpServerRequest, result: HelloWorldResponse?): HttpServerResponse { //(2)!
            requireNotNull(result)
            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")
    }
}
  1. Как и мапперы запроса, класс внедряется в сгенерированный модуль и обязан быть компонентом графа
  2. Контракт объявляет результат nullable, поэтому Kotlin-переопределение обязано принимать HelloWorldResponse? — иначе оно не будет распознано как override

Маршруты

Маршрут складывается из префикса пути @HttpController и пути @HttpRoute:

@Component
@HttpController("/api/v1") //(1)!
public final class SomeController {

    @HttpRoute(method = HttpMethod.GET, path = "/pets/{id}") //(2)!
    public String get(@Path long id) {
        return "OK";
    }

    @HttpRoute(method = HttpMethod.GET, path = "/files/*") //(3)!
    public String file() {
        return "OK";
    }
}
  1. Префикс пути, применяемый ко всем маршрутам контроллера
  2. Маршрут с параметром пути, итоговый маршрут — /api/v1/pets/{id}
  3. Маршрут с завершающим шаблоном, итоговый маршрут — /api/v1/files/*
@Component
@HttpController("/api/v1") //(1)!
class SomeController {

    @HttpRoute(method = HttpMethod.GET, path = "/pets/{id}") //(2)!
    fun get(@Path id: Long): String = "OK"

    @HttpRoute(method = HttpMethod.GET, path = "/files/*") //(3)!
    fun file(): String = "OK"
}
  1. Префикс пути, применяемый ко всем маршрутам контроллера
  2. Маршрут с параметром пути, итоговый маршрут — /api/v1/pets/{id}
  3. Маршрут с завершающим шаблоном, итоговый маршрут — /api/v1/files/*

Правила сопоставления маршрутов:

  • Параметр пути {name} соответствует одному сегменту пути.
  • Шаблон * допустим один раз и только в последнем сегменте: /files/*, /files/*.js, /files/file-*.txt, /tenant/{id}/report-*.json. Все остальное (/foo/*/bar, /foo/**, /foo/a*b*c, /foo/{*}) не компилируется с ошибкой HTTP server route path is invalid.
  • Два обработчика с эквивалентными шаблонами для одного метода приводят к падению сервера на старте с сообщением Cannot add path template ..., matcher already contains an equivalent pattern ....
  • Неизвестный путь отвечает 404; известный путь с неподдерживаемым методом отвечает 405 и заголовком Allow со списком зарегистрированных методов.
  • При httpServer.ignoreTrailingSlash = true для каждого маршрута без шаблона регистрируется дополнительный вариант, поэтому /my/path и /my/path/ попадают в один обработчик.

Сигнатуры

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

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

  • T myMethod()
  • void myMethod() — отвечает 200 с пустым телом

Возврат CompletionStage<T>, Future<T> либо реактивного Publisher<T> не поддерживается: процессор выводит предупреждение, что такой тип "is unsupported and has no meaning", а сборка графа затем падает, потому что для такого типа нет HttpServerResponseMapper.

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

  • myMethod(): T
  • myMethod(): Unit — отвечает 200 с пустым телом

suspend-методы не поддерживаются и отклоняются на этапе компиляции с сообщением "Suspend methods are not supported by the HTTP server controller generator". Для параллельной работы внутри обработчика используйте StructuredTaskScope вместо корутин.

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

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

public interface HttpServerInterceptor {
    HttpServerResponse intercept(HttpServerRequest request, InterceptChain chain) throws Exception;

    interface InterceptChain {
        HttpServerResponse process(HttpServerRequest request) throws Exception;
    }
}

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

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

  • К конкретным методам контроллера — @InterceptWith на методе
  • Ко всему контроллеру — @InterceptWith на классе
  • Сразу ко всем контроллерам — зарегистрировать компонент перехватчика с тегом @Tag(HttpServer.class); глобальных перехватчиков может быть несколько

@InterceptWith — повторяемая аннотация, при этом перехватчики, объявленные на классе, выполняются раньше объявленных на методе. Глобальные перехватчики применяются в детерминированном порядке, отсортированном по простому имени класса перехватчика.

Порядок выполнения:

Перехватчики, объявленные через @InterceptWith, выполняются в порядке объявления (сверху вниз): перехватчики уровня контроллера оборачивают перехватчики уровня метода, а в пределах одной цели порядок соответствует порядку аннотаций. Каждый перехватчик может изменить запрос перед chain.process(...), прервать цепочку, вернув ответ без её вызова, либо обработать результат/исключение после.

Глобальные перехватчики, зарегистрированные через @Tag(HttpServerModule.class), не имеют гарантированного порядка между собой. Если требуется строгий порядок между глобальными обработчиками (например, авторизация должна выполняться до обработки ошибок), не регистрируйте несколько глобальных перехватчиков — реализуйте один глобальный перехватчик, который вызывает нужные шаги в требуемом порядке внутри себя.

@Component
@HttpController
@InterceptWith(SomeController.ControllerInterceptor.class) //(1)!
public final class SomeController {

    @Component
    public static final class ControllerInterceptor implements HttpServerInterceptor {

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

    @Component
    public static final class MethodInterceptor implements HttpServerInterceptor {

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

    @Tag(HttpServer.class) //(2)!
    @Component
    public static final class ServerInterceptor implements HttpServerInterceptor {

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

    @InterceptWith(MethodInterceptor.class) //(3)!
    @HttpRoute(method = HttpMethod.POST, path = "/intercepted")
    public String helloWorld() {
        return "Hello World";
    }
}
  1. Перехватывает все маршруты этого контроллера
  2. Перехватывает все маршруты публичного сервера, включая ответы 404 и 405
  3. Перехватывает только этот маршрут
@Component
@HttpController
@InterceptWith(SomeController.ControllerInterceptor::class) //(1)!
class SomeController {

    @Component
    class ControllerInterceptor : HttpServerInterceptor {

        override fun intercept(request: HttpServerRequest, chain: HttpServerInterceptor.InterceptChain): HttpServerResponse {
            return chain.process(request)
        }
    }

    @Component
    class MethodInterceptor : HttpServerInterceptor {

        override fun intercept(request: HttpServerRequest, chain: HttpServerInterceptor.InterceptChain): HttpServerResponse {
            return chain.process(request)
        }
    }

    @Tag(HttpServer::class) //(2)!
    @Component
    class ServerInterceptor : HttpServerInterceptor {

        override fun intercept(request: HttpServerRequest, chain: HttpServerInterceptor.InterceptChain): HttpServerResponse {
            return chain.process(request)
        }
    }

    @InterceptWith(MethodInterceptor::class) //(3)!
    @HttpRoute(method = HttpMethod.POST, path = "/intercepted")
    fun helloWorld(): String {
        return "Hello World"
    }
}
  1. Перехватывает все маршруты этого контроллера
  2. Перехватывает все маршруты публичного сервера, включая ответы 404 и 405
  3. Перехватывает только этот маршрут
Тег глобального перехватчика

Фреймворк собирает глобальные перехватчики только по тегу @Tag(HttpServer.class) — в HttpServerModule зависимость объявлена как @Tag(HttpServer.class) All<HttpServerInterceptor> interceptors. Любой другой тег компилируется без ошибок, а перехватчик просто никогда не вызывается, и обработка ошибок, аутентификация или логирование исчезают без единого предупреждения. Проверяйте это тестом.

Чтобы перехватывать все запросы системного сервера, используйте тег @SystemApi.

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

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

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

    private static final Logger logger = LoggerFactory.getLogger(ErrorInterceptor.class);

    private final JsonWriter<ErrorTO> errorWriter;

    public ErrorInterceptor(JsonWriter<ErrorTO> errorWriter) { //(1)!
        this.errorWriter = errorWriter;
    }

    @Override
    public HttpServerResponse intercept(HttpServerRequest request, InterceptChain chain) {
        try {
            return chain.process(request);
        } catch (HttpServerResponseException e) { //(2)!
            return e;
        } catch (Exception e) {
            var body = HttpBody.json(errorWriter.toByteArray(new ErrorTO(e.getMessage()))); //(3)!
            if (e instanceof IllegalArgumentException) {
                return HttpServerResponse.of(400, body);
            } else if (e instanceof TimeoutException) {
                return HttpServerResponse.of(408, body);
            } else {
                logger.error("Request '{} {}' failed", request.method(), request.path(), e);
                return HttpServerResponse.of(500, body);
            }
        }
    }
}
  1. У перехватчика есть зависимость в конструкторе, поэтому он обязан быть компонентом графа
  2. HttpServerResponseException сам является ответом, поэтому он возвращается в том виде, в каком его должен увидеть клиент
  3. JsonWriter.toByteArray(...) не объявляет проверяемых исключений, поэтому обрабатывать IOException не нужно
@Tag(HttpServer::class)
@Component
class ErrorInterceptor(private val errorWriter: JsonWriter<ErrorTO>) : HttpServerInterceptor { //(1)!

    private val logger = LoggerFactory.getLogger(ErrorInterceptor::class.java)

    override fun intercept(request: HttpServerRequest, chain: HttpServerInterceptor.InterceptChain): HttpServerResponse {
        try {
            return chain.process(request)
        } catch (e: HttpServerResponseException) { //(2)!
            return e
        } catch (e: Exception) {
            val body = HttpBody.json(errorWriter.toByteArray(ErrorTO(e.message))) //(3)!
            return when (e) {
                is IllegalArgumentException -> HttpServerResponse.of(400, body)
                is TimeoutException -> HttpServerResponse.of(408, body)
                else -> {
                    logger.error("Request '{} {}' failed", request.method(), request.path(), e)
                    HttpServerResponse.of(500, body)
                }
            }
        }
    }
}
  1. У перехватчика есть зависимость в конструкторе, поэтому он обязан быть компонентом графа
  2. HttpServerResponseException сам является ответом, поэтому он возвращается в том виде, в каком его должен увидеть клиент
  3. JsonWriter.toByteArray(...) не объявляет проверяемых исключений, поэтому обрабатывать IOException не нужно

Ошибки разбора параметров Kora обрабатывает до вызова метода контроллера: значение, которое не удалось прочитать, приводит к ответу 400 с сообщением от HttpServerParameterReader. Такой ответ тоже проходит через цепочку перехватчиков, поэтому глобальный обработчик может его переформировать.

SomeController императивный

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

Следующий пример показывает, как обработать все описанные выше декларативные параметры запроса:

@Module
public interface SomeModule {

    default HttpServerRequestHandler someHttpHandler() {
        return HttpServerRequestHandlerImpl.of(HttpMethod.POST, //(1)!
                                               "/hello/{world}", //(2)!
                                               (request) -> {
            var path = HttpRequestHandlerUtils.parsePathString(request, "world");
            var query = HttpRequestHandlerUtils.parseQueryStringNullable(request, "query");
            var queries = HttpRequestHandlerUtils.parseQueryStringListNullable(request, "Queries");
            var header = HttpRequestHandlerUtils.parseHeaderStringNullable(request, "header");
            var headers = HttpRequestHandlerUtils.parseHeaderStringListNullable(request, "Headers");
            return HttpServerResponse.of(200, HttpBody.plaintext("Hello World"));
        });
    }
}
  1. Указывает тип HTTP метода обработчика
  2. Указывает путь метода-обработчика
@Module
interface SomeModule {

    fun someHttpHandler(): HttpServerRequestHandler {
        return HttpServerRequestHandlerImpl.of(
            HttpMethod.POST, //(1)!
            "/hello/{world}" //(2)!
        ) { request: HttpServerRequest ->
            val path = HttpRequestHandlerUtils.parsePathString(request, "world")
            val query = HttpRequestHandlerUtils.parseQueryStringNullable(request, "query")
            val queries = HttpRequestHandlerUtils.parseQueryStringListNullable(request, "Queries")
            val header = HttpRequestHandlerUtils.parseHeaderStringNullable(request, "header")
            val headers = HttpRequestHandlerUtils.parseHeaderStringListNullable(request, "Headers")
            HttpServerResponse.of(200, HttpBody.plaintext("Hello World"))
        }
    }
}
  1. Указывает тип HTTP метода обработчика
  2. Указывает путь метода-обработчика

У HttpServerRequestHandlerImpl есть и сокращенные фабрики под каждый метод — get, head, post, put, delete, connect, options, trace, patch — а также перегрузка с флагом enabled, позволяющая исключить обработчик из маршрутизации, не убирая его из графа.

HttpServerRequestHandler без тега регистрируется на публичном сервере; обработчик с тегом @SystemApi регистрируется на системном сервере.

Авторизация

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

Как это работает

HttpServerPrincipalExtractor<T, P> получает извлеченные из запроса учетные данные и возвращает объект Principal либо null, если данные не приняты.

public interface HttpServerPrincipalExtractor<T, P extends Principal> {
    @Nullable
    P extract(HttpServerRequest request, @Nullable T token);
}

Где:

  • request — текущий HTTP-запрос, из которого можно извлечь дополнительные данные (заголовки, параметры)
  • token — учетные данные, взятые из запроса (заголовок Authorization, заголовок с API-ключом, параметр запроса либо cookie)
  • T — тип учетных данных: String для одной схемы безопасности либо сгенерированная запись AuthData, если схем несколько
  • P extends Principal — тип контекста авторизации

Экстрактор вызывается перехватчиками, сгенерированными из OpenAPI-контракта — смотрите Интеграцию с OpenAPI. Сгенерированный перехватчик читает учетные данные, вызывает extract(...) и при ненулевом результате выполняет остаток цепочки внутри Principal.with(principal, () -> chain.process(request)). Если результат null (либо не хватает требуемых scope), он выбрасывает HttpServerResponseException.of(401, "Unauthorized").

Для сервиса без OpenAPI-контракта пишется обычный перехватчик — смотрите Авторизацию без OpenAPI.

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

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

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

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

public record UserPrincipal(String userId, List<String> roles) implements Principal {}
data class UserPrincipal(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-ключа, где ApiKeyAuth — имя схемы безопасности из OpenAPI-контракта:

@Module
public interface AuthModule {

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

    @Tag(ApiSecurity.ApiKeyAuth.class) //(1)!
    default HttpServerPrincipalExtractor<String, Principal> apiKeyExtractor(ApiKeyAuthConfig config) {
        return (request, value) -> {
            if (value == null || !config.value().equals(value)) {
                return null; //(2)!
            }
            return new ApiPrincipal("api-client");
        };
    }
}
  1. Тег назван по имени схемы безопасности из контракта
  2. Возврат null заставляет сгенерированный перехватчик ответить 401 Unauthorized
@Module
interface AuthModule {

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

    @Tag(ApiSecurity.ApiKeyAuth::class) //(1)!
    fun apiKeyExtractor(config: ApiKeyAuthConfig): HttpServerPrincipalExtractor<String, Principal> {
        return HttpServerPrincipalExtractor { request, value ->
            if (value == null || config.value() != value) {
                null //(2)!
            } else {
                ApiPrincipal("api-client")
            }
        }
    }
}
  1. Тег назван по имени схемы безопасности из контракта
  2. Возврат null заставляет сгенерированный перехватчик ответить 401 Unauthorized

Bearer-токен

Пример проверки Bearer-токена с собственной реализацией Principal. Для схем Bearer, Basic и OAuth сгенерированный перехватчик передает целиком значение заголовка Authorization:

@Module
public interface BearerAuthModule {

    @Tag(ApiSecurity.BearerAuth.class)
    default HttpServerPrincipalExtractor<String, Principal> bearerExtractor(TokenValidator validator) {
        return (request, value) -> {
            if (value == null || !value.startsWith("Bearer ")) {
                return null;
            }

            var token = value.substring("Bearer ".length());
            var userData = validator.validate(token);
            return userData == null
                ? null
                : new UserPrincipal(userData.userId(), userData.roles());
        };
    }
}
@Module
interface BearerAuthModule {

    @Tag(ApiSecurity.BearerAuth::class)
    fun bearerExtractor(validator: TokenValidator): HttpServerPrincipalExtractor<String, Principal> {
        return HttpServerPrincipalExtractor { request, value ->
            if (value == null || !value.startsWith("Bearer ")) {
                null
            } else {
                val token = value.substring("Bearer ".length)
                validator.validate(token)
                    ?.let { UserPrincipal(it.userId, it.roles) }
            }
        }
    }
}

Получение Principal

Текущий контекст авторизации привязан к ScopedValue и доступен в любом месте обработки запроса:

@Component
@HttpController
public class SecureController {

    @HttpRoute(method = HttpMethod.GET, path = "/secure")
    public String getSecureData() {
        Principal principal = Principal.current(); //(1)!
        if (principal instanceof UserPrincipal user) {
            return "Hello, user: " + user.userId();
        }
        throw new SecurityException("Not authenticated");
    }
}
  1. Возвращает null, если для текущего запроса principal не привязан
@Component
@HttpController
class SecureController {

    @HttpRoute(method = HttpMethod.GET, path = "/secure")
    fun getSecureData(): String {
        val principal = Principal.current() //(1)!
        return if (principal is UserPrincipal) {
            "Hello, user: ${principal.userId}"
        } else {
            throw SecurityException("Not authenticated")
        }
    }
}
  1. Возвращает null, если для текущего запроса principal не привязан

OAuth2

Для авторизации OAuth2 создается HttpServerPrincipalExtractor, который проверяет токен через OAuth2-провайдер. Для схемы безопасности OAuth сгенерированный код ожидает PrincipalWithScopes:

@Module
public interface OAuth2Module {

    @Tag(ApiSecurity.OAuth.class)
    default HttpServerPrincipalExtractor<String, PrincipalWithScopes> oauth2Extractor(OAuth2Client oauth2Client) {
        return (request, value) -> {
            if (value == null || !value.startsWith("Bearer ")) {
                return null;
            }

            var token = value.substring("Bearer ".length());
            var introspection = oauth2Client.introspect(token);
            return introspection == null
                ? null
                : new ScopedUser(introspection.subject(), introspection.scopes());
        };
    }
}
@Module
interface OAuth2Module {

    @Tag(ApiSecurity.OAuth::class)
    fun oauth2Extractor(oauth2Client: OAuth2Client): HttpServerPrincipalExtractor<String, PrincipalWithScopes> {
        return HttpServerPrincipalExtractor { request, value ->
            if (value == null || !value.startsWith("Bearer ")) {
                null
            } else {
                val token = value.substring("Bearer ".length)
                oauth2Client.introspect(token)
                    ?.let { ScopedUser(it.subject, it.scopes) }
            }
        }
    }
}

Проверка Scope

Если scope объявлены в OpenAPI-контракте, сгенерированный перехватчик проверяет их сам: если возвращенный PrincipalWithScopes.scopes() не содержит требуемого scope, запрос завершается ответом 401.

Вне OpenAPI перехватчик проверяет scope и сам привязывает principal через Principal.with(...), чтобы остаток цепочки мог прочитать его через Principal.current():

@Component
public final class ScopeCheckingInterceptor implements HttpServerInterceptor {

    private final AuthConfig config;
    private final TokenValidator validator;

    public ScopeCheckingInterceptor(AuthConfig config, TokenValidator validator) {
        this.config = config;
        this.validator = validator;
    }

    @Override
    public HttpServerResponse intercept(HttpServerRequest request, InterceptChain chain) throws Exception {
        var principal = validator.validate(request.headers().getFirst("authorization"));
        if (!(principal instanceof PrincipalWithScopes scoped)) {
            throw HttpServerResponseException.of(403, "No scopes available");
        }
        if (!scoped.scopes().contains(config.requiredScope())) {
            throw HttpServerResponseException.of(403, "Insufficient scope");
        }

        return Principal.with(scoped, () -> chain.process(request)); //(1)!
    }
}
  1. Привязывает principal к текущему запросу, чтобы дальше по цепочке Principal.current() возвращал его
@Component
class ScopeCheckingInterceptor(
    private val config: AuthConfig,
    private val validator: TokenValidator
) : HttpServerInterceptor {

    override fun intercept(request: HttpServerRequest, chain: HttpServerInterceptor.InterceptChain): HttpServerResponse {
        val principal = validator.validate(request.headers().getFirst("authorization"))
        if (principal !is PrincipalWithScopes) {
            throw HttpServerResponseException.of(403, "No scopes available")
        }
        if (!principal.scopes.contains(config.requiredScope())) {
            throw HttpServerResponseException.of(403, "Insufficient scope")
        }

        return Principal.with(principal) { chain.process(request) } //(1)!
    }
}
  1. Привязывает principal к текущему запросу, чтобы дальше по цепочке Principal.current() возвращал его

Интеграция с OpenAPI

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

  1. Интерфейс ApiSecurity с классом-маркером для каждой схемы безопасности, названным по имени схемы (ApiKeyAuth, BearerAuth, BasicAuth, CookieAuth, OAuth)
  2. HttpServerInterceptor для каждого требования безопасности, применяемый к сгенерированному контроллеру
  3. Требование предоставить HttpServerPrincipalExtractor с соответствующим @Tag

Пример из kora-examples:

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

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

где DataApiPrincipal:

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

    @Tag(ApiSecurity.ApiKeyAuth::class)
    fun apiKeyExtractor(config: DataApiAuthConfig): HttpServerPrincipalExtractor<String, Principal> {
        return HttpServerPrincipalExtractor { request, value ->
            if (value == null || config.value() != value) {
                null
            } else {
                DataApiPrincipal("data-api-client")
            }
        }
    }
}

где DataApiPrincipal:

data class DataApiPrincipal(val name: String) : Principal

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

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

Если одна операция требует сразу несколько схем, тег экстрактора склеивает имена схем через With (BearerAuthWithApiKeyAuth), а генератор добавляет запись ApiSecurity.<Tag>AuthData со всеми учетными данными, поэтому экстрактор объявляется как HttpServerPrincipalExtractor<ApiSecurity.BearerAuthWithApiKeyAuthAuthData, Principal>. Перехватчик для такого требования получает отдельный тег, где имена схем склеены через And (ApiSecurity.BearerAuthAndApiKeyAuth); альтернативные требования одной операции склеиваются через _.

Авторизация без OpenAPI

Без сгенерированного ApiSecurity никто не вызывает HttpServerPrincipalExtractor, поэтому авторизация реализуется обычным перехватчиком на контроллере, на маршруте либо глобально:

@Component
public final class ApiKeyAuthInterceptor implements HttpServerInterceptor {

    private final ApiKeyAuthConfig config;

    public ApiKeyAuthInterceptor(ApiKeyAuthConfig config) {
        this.config = config;
    }

    @Override
    public HttpServerResponse intercept(HttpServerRequest request, InterceptChain chain) throws Exception {
        var authorization = request.headers().getFirst("authorization");
        if (!this.config.value().equals(authorization)) {
            throw new SecurityException("Invalid API key"); //(1)!
        }
        return chain.process(request);
    }
}
  1. Исключение превращается в 403 глобальным обработчиком ошибок авторизации
@Component
class ApiKeyAuthInterceptor(private val config: ApiKeyAuthConfig) : HttpServerInterceptor {

    override fun intercept(request: HttpServerRequest, chain: HttpServerInterceptor.InterceptChain): HttpServerResponse {
        val authorization = request.headers().getFirst("authorization")
        if (config.value() != authorization) {
            throw SecurityException("Invalid API key") //(1)!
        }
        return chain.process(request)
    }
}
  1. Исключение превращается в 403 глобальным обработчиком ошибок авторизации

Далее перехватчик подключается через @InterceptWith(ApiKeyAuthInterceptor.class) на контроллере либо на отдельном маршруте.

Обработка ошибок авторизации

Когда экстрактор возвращает null либо не хватает требуемых scope, сгенерированный перехватчик отвечает статусом 401 и телом Unauthorized. Чтобы формировать ошибки авторизации самостоятельно, добавьте глобальный перехватчик:

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

    @Override
    public HttpServerResponse intercept(HttpServerRequest request, InterceptChain chain) throws Exception {
        try {
            return chain.process(request);
        } catch (IllegalAccessException e) {
            return HttpServerResponse.of(401, HttpBody.plaintext("Unauthorized: " + e.getMessage()));
        } catch (SecurityException e) {
            return HttpServerResponse.of(403, HttpBody.plaintext("Forbidden: " + e.getMessage()));
        }
    }
}
@Tag(HttpServer::class)
@Component
class AuthErrorInterceptor : HttpServerInterceptor {

    override fun intercept(request: HttpServerRequest, chain: HttpServerInterceptor.InterceptChain): HttpServerResponse {
        try {
            return chain.process(request)
        } catch (e: IllegalAccessException) {
            return HttpServerResponse.of(401, HttpBody.plaintext("Unauthorized: ${e.message}"))
        } catch (e: SecurityException) {
            return HttpServerResponse.of(403, HttpBody.plaintext("Forbidden: ${e.message}"))
        }
    }
}

Поскольку глобальный перехватчик оборачивает сгенерированный перехватчик безопасности, он также видит HttpServerResponseException с кодом 401, выброшенный сгенерированным кодом, и может заменить его собственным ответом.

Телеметрия

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

На каждый HTTP-запрос создается HttpServerObservation, который закрывается при завершении запроса. Он наблюдает запрос, ответ, HttpResultCode и возникшее исключение.

Фабрика по умолчанию DefaultHttpServerTelemetryFactory объединяет три части:

  • DefaultHttpServerLoggerFactory создает логгер начала и завершения запроса;
  • DefaultHttpServerMetricsFactory создает метрики запроса;
  • io.opentelemetry.api.trace.Tracer, если он есть в графе, создает спан запроса.

Логи запроса и ответа пишутся двумя отдельными логгерами, поэтому их уровень настраивается независимо:

logging.levels {
    "io.koraframework.http.server.common.HttpServer.request" = "DEBUG" //(1)!
    "io.koraframework.http.server.common.HttpServer.response" = "TRACE" //(2)!
}
  1. На уровне INFO логируется только операция, DEBUG добавляет заголовки и параметры запроса
  2. TRACE дополнительно пишет тело, ограниченное maxRequestBodyLogSize / maxResponseBodyLogSize
logging:
  levels:
    "io.koraframework.http.server.common.HttpServer.request": "DEBUG" #(1)!
    "io.koraframework.http.server.common.HttpServer.response": "TRACE" #(2)!
  1. На уровне INFO логируется только операция, DEBUG добавляет заголовки и параметры запроса
  2. TRACE дополнительно пишет тело, ограниченное maxRequestBodyLogSize / maxResponseBodyLogSize

Заголовки из maskHeaders и параметры запроса из maskQueries заменяются значением mask. В логируемой операции по умолчанию используется шаблон маршрута, а полный путь — при pathFull = true либо на уровне логгера TRACE.

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

Логирование

Логирование сервера пишется через SLF4J под логгером ru.tinkoff.kora.http.server.common.HttpServer. Включение логирования в конфигурации (httpServer.telemetry.logging.enabled = true) активирует телеметрию, но что именно пишется, определяется уровнем логирования этого логгера, поэтому детализацией вы управляете из вашего фреймворка логирования (logback и т.д.) без перезапуска с другой конфигурацией:

Уровень лога Что логируется
INFO Строка начала и конца запроса: метод, шаблон пути, статус ответа, код результата и длительность
DEBUG Дополнительно заголовки запроса и ответа
TRACE Дополнительно полный (нешаблонизированный) путь запроса

Следующие поля конфигурации формируют вывод (полный список см. в Конфигурации):

  • pathTemplate — при true (по умолчанию) логируется шаблон маршрута с низкой кардинальностью (/users/{id}) и используется как метка метрик/трассировки вместо разрешённого пути (/users/42); на TRACE логируется разрешённый путь
  • maskHeaders — имена заголовков, значения которых заменяются на mask (по умолчанию маскируются authorization, cookie, set-cookie)
  • maskQueries — имена query-параметров, значения которых заменяются на mask
  • mask — строка замены (по умолчанию ***)
  • stacktrace — при true (по умолчанию) логирует стектрейс исключения при ошибке обработки запроса

Пример конфигурации logback, включающей логирование заголовков сервера:

<logger name="ru.tinkoff.kora.http.server.common.HttpServer" level="DEBUG"/>

Свой логгер

Чтобы полностью управлять форматом или назначением лога, предоставьте свой компонент HttpServerLoggerFactory (или HttpServerLogger) — он заменит фабрику по умолчанию Slf4jHttpServerLoggerFactory. То же касается метрик (HttpServerMetricsFactory) и трассировки (HttpServerTracerFactory): предоставление любого из этих компонентов переопределяет соответствующую реализацию по умолчанию, остальные сохраняют реализацию по умолчанию.

@Component
public final class MyHttpServerLoggerFactory implements HttpServerLoggerFactory {

    @Override
    public HttpServerLogger get(HttpServerLoggerConfig logging) {
        return new MyHttpServerLogger(); //(1)!
    }
}
  1. Ваша реализация HttpServerLogger, управляющая тем, что и как логировать
@Component
class MyHttpServerLoggerFactory : HttpServerLoggerFactory {

    override fun get(logging: HttpServerLoggerConfig): HttpServerLogger {
        return MyHttpServerLogger() //(1)!
    }
}
  1. Ваша реализация HttpServerLogger, управляющая тем, что и как логировать