Перейти к содержанию
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)!
    headerServerNameEnabled = false //(8)!
    headerServerDateEnabled = true //(9)!
    maxRequestBodySize = "256MiB" //(10)!
    telemetry {
        logging {
            enabled = false //(11)!
            stacktrace = true //(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. Отправлять ли заголовок ответа Server: Kora (по умолчанию: false)
  9. Всегда ли отправлять заголовок ответа Date (по умолчанию: true)
  10. Максимально допустимый размер тела входящего запроса (по умолчанию: 256MiB)
  11. Включает логирование модуля (по умолчанию: false)
  12. Включает логирование стектрейса при исключении (по умолчанию: true)
  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)!
  headerServerNameEnabled: false #(8)!
  headerServerDateEnabled: true #(9)!
  maxRequestBodySize: "256MiB" #(10)!
  telemetry:
    logging:
      enabled: false #(11)!
      stacktrace: true #(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. Отправлять ли заголовок ответа Server: Kora (по умолчанию: false)
  9. Всегда ли отправлять заголовок ответа Date (по умолчанию: true)
  10. Максимально допустимый размер тела входящего запроса (по умолчанию: 256MiB)
  11. Включает логирование модуля (по умолчанию: false)
  12. Включает логирование стектрейса при исключении (по умолчанию: true)
  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 больше нет. Тело ответа записывается в сокет через NIO потоками ввода-вывода Undertow. Тело, содержимое которого еще не находится в памяти, буферизуется на виртуальном потоке запроса и отправляется целиком, пока укладывается в 64KiB; большее тело передается потоком через ограниченный канал, поэтому большой ответ никогда не держится в памяти полностью.

Для всего, что не вынесено в конфигурацию, 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<XnioWorker.Builder> workerConfigurer() { //(2)!
        return builder -> builder.setWorkerName("my-worker");
    }
}
  1. Настраивает билдер Undertow публичного сервера до его старта
  2. Настраивает XnioWorker, общий для обоих серверов
@KoraApp
interface Application : UndertowPublicHttpServerModule {

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

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

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

Собственный внутренний HTTP-сервер

UndertowHttpServerFactoryModule позволяет поднять дополнительный сервер со своими маршрутами и конфигурацией. Пометьте фабричный модуль и контроллер одним тегом: сгенерированные обработчики получат этот тег и попадут только на соответствующий сервер.

Разместите InternalHttpModule в том же модуле компиляции, что и @KoraApp: @Module подключает его автоматически. В интерфейсе приложения сохраните UndertowSystemHttpServerModule или UndertowPublicHttpServerModule. UndertowSystemHttpServerModule предоставляет общие компоненты Undertow и системный сервер; если уже подключён UndertowPublicHttpServerModule, публичный сервер также продолжит работать. Используется та же зависимость http-server-undertow.

@Module
public interface InternalHttpModule {
    interface InternalApi { }

    @FactoryModule
    @Tag(InternalApi.class)
    default UndertowHttpServerFactoryModule internalHttpApi() {
        return new UndertowHttpServerFactoryModule("kora-undertow-internal", "httpServer.internal");
    }
}
@Component
@HttpController
@Tag(InternalHttpModule.InternalApi.class)
public final class InternalHelloController {
    @HttpRoute(method = HttpMethod.GET, path = "/hello")
    public String hello() {
        return "Hello World";
    }
}
@Module
interface InternalHttpModule {
    interface InternalApi

    @FactoryModule
    @Tag(InternalApi::class)
    fun internalHttpApi(): UndertowHttpServerFactoryModule =
        UndertowHttpServerFactoryModule("kora-undertow-internal", "httpServer.internal")
}
@Component
@HttpController
@Tag(InternalHttpModule.InternalApi::class)
class InternalHelloController {
    @HttpRoute(method = HttpMethod.GET, path = "/hello")
    fun hello(): String = "Hello World"
}

Первый аргумент фабрики — имя сервера для потоков и телеметрии, второй — путь к секции HttpServerConfig. В конфигурации приложения задайте httpServer.internal.port, например 8086; остальные настройки этого сервера также задаются в httpServer.internal.

GET http://localhost:8086/hello вернёт Hello World. Общие транспортные настройки остаются в httpServer.undertow. Для перехватчиков HttpServerInterceptor и Configurer<Undertow.Builder> внутреннего сервера используйте тот же тег InternalApi. Тег разделяет маршруты; доступ к порту ограничивается настройками сети. Аналогичный пример с OpenAPI — в OpenAPI management.

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 (из io.koraframework.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 из отправленной формы; 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 из отправленной формы; FormUrlEncoded.get(String) возвращает FormPart(String name, List<String> values) либо null
Form Multipart

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

FormMultipart.parts() возвращает список частей. FormMultipart.FormPart — sealed-интерфейс с подтипами MultipartData, MultipartFile и MultipartFileStream, но сервер читает запрос в память целиком и отдает каждую часть, включая текстовые поля, как MultipartFile: name(), fileName(), contentType() и content() (byte[]). MultipartData и MultipartFileStream используются при отправке формы, например HTTP-клиентом.

@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.MultipartFile file) { //(1)!
                String name = file.name();
                String fileName = file.fileName(); //(2)!
                String contentType = file.contentType();
                byte[] content = file.content();
            }
        }
        return "OK";
    }
}
  1. Каждая часть входящей формы — MultipartFile
  2. fileName() и contentType() равны null, если часть их не объявляет, как у обычного текстового поля
@Component
@HttpController
class SomeController {

    @HttpRoute(method = HttpMethod.POST, path = "/form/multipart")
    fun handle(body: FormMultipart): String {
        for (part in body.parts()) {
            if (part is FormMultipart.FormPart.MultipartFile) { //(1)!
                val name = part.name()
                val fileName = part.fileName() //(2)!
                val contentType = part.contentType()
                val content = part.content()
            }
        }
        return "OK"
    }
}
  1. Каждая часть входящей формы — MultipartFile
  2. fileName() и contentType() равны null, если часть их не объявляет, как у обычного текстового поля

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

Потоковая передача через InputStream

Верните HttpBodyOutput.of(contentType, inputStream), чтобы отправить тело без чтения всего файла в массив байтов. Откройте поток в контроллере и передайте владение телу ответа: сервер закрывает его после обработки ответа, в том числе при ошибке записи. Не закрывайте возвращаемый поток через try-with-resources / use в контроллере — иначе сервер получит уже закрытый поток.

@HttpRoute(method = HttpMethod.GET, path = "/report")
public HttpServerResponse report() throws java.io.IOException {
    var path = java.nio.file.Path.of("/data/report.csv");
    var stream = java.nio.file.Files.newInputStream(path);
    return HttpServerResponse.of(200, HttpBodyOutput.of("text/csv", stream));
}
@HttpRoute(method = HttpMethod.GET, path = "/report")
fun report(): HttpServerResponse {
    val path = java.nio.file.Path.of("/data/report.csv")
    val stream = java.nio.file.Files.newInputStream(path)
    return HttpServerResponse.of(200, HttpBodyOutput.of("text/csv", stream))
}

Импортируйте HttpBodyOutput из io.koraframework.http.common.body. Фиксированный путь к файлу на сервере в примере не зависит от пользовательского ввода. Если точная длина известна и содержимое не меняется во время передачи, используйте of(contentType, length, stream) или octetStream(length, stream); иначе оставьте длину неизвестной (-1). HTTP/1.1 может передавать тело по частям, а HTTP/2 — в собственных кадрах данных.

Undertow буферизует небольшое тело до 64KiB, а для больших использует канал с ограниченным буфером: скорость записи в сокет ограничивает скорость чтения источника. Чтение InputStream выполняется блокирующим кодом на виртуальном потоке запроса. Разрыв соединения с клиентом приводит к ошибке записи, но не гарантирует немедленную остановку произвольного блокирующего чтения — задавайте ограничение времени чтения источника. После отправки заголовков ошибку чтения или записи уже нельзя заменить JSON-ответом. Сервер передаёт поток байтов, а не формирует события SSE.

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). Авторизация определяет, разрешена ли ему операция. Одно лишь извлечение и проверка токена не проверяет владение ресурсом и бизнес-права: проверяйте их также в сервисе, если он вызывается не только через HTTP. Kora не определяет роли приложения автоматически по вашему типу Principal.

В собственном перехватчике используйте HttpServerResponseException.of(401, "Unauthorized") при отсутствии или некорректности учётных данных и of(403, "Forbidden") для аутентифицированного пользователя без нужных прав. Привязывайте проверенный Principal через Principal.with(...) вокруг chain.process(request). Само по себе SecurityException не задаёт код HTTP-ответа; в примере это делает перехватчик ошибок. В RC2 сгенерированные обработчики безопасности OpenAPI возвращают 401, если ни одно требование аутентификации не выполнено, и 403, если пользователь аутентифицирован, но ему не хватает требуемых разрешений (scopes).

Проверка прав в сервисе

Проверяйте бизнес-права в сервисе через описанный ниже UserPrincipal, даже если HTTP-запрос уже прошёл аутентификацию:

public void requireAdmin() {
    var principal = Principal.current();
    if (principal == null) {
        throw HttpServerResponseException.of(401, "Unauthorized");
    }
    if (!(principal instanceof UserPrincipal user) || !user.roles().contains("admin")) {
        throw HttpServerResponseException.of(403, "Forbidden");
    }
}
fun requireAdmin() {
    val principal = Principal.current()
        ?: throw HttpServerResponseException.of(401, "Unauthorized")
    if (principal !is UserPrincipal || "admin" !in principal.roles) {
        throw HttpServerResponseException.of(403, "Forbidden")
    }
}

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

Передача данных пользователя и исходящие запросы

Principal.current() читает ScopedValue, доступный внутри Principal.with(...), в том числе при синхронных вызовах контроллеров и сервисов. Задача, запущенная на независимом потоке, автоматически не получает эту привязку. При необходимости сохраните проверенный Principal и явно привяжите его внутри задачи; не храните данные пользователя текущего запроса в полях общих компонентов.

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

Проверка аутентификации и прав доступа

Проверьте запросы без учётных данных, с некорректными учётными данными, с корректными данными, но недостаточными разрешениями или ролями, и с нужными правами. Проверяйте код ответа и отсутствие вызовов защищённого бизнес-кода при отказе в доступе. Для проверок на уровне сервиса используйте Principal.with(testPrincipal, () -> service.operation()), а после выполнения функции проверьте отсутствие привязки Principal.current(). Подмена проверки токена и внедрение сервиса описаны в JUnit5.

Пользовательский 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() не содержит требуемого разрешения, запрос завершается ответом 403. При отсутствии подходящих учётных данных возвращается 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 == null) {
            throw HttpServerResponseException.of(401, "Unauthorized");
        }
        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"))
            ?: throw HttpServerResponseException.of(401, "Unauthorized")
        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 HttpServerResponseException.of(401, "Unauthorized"); //(1)!
        }
        return chain.process(request);
    }
}
  1. Отсутствующие или некорректные учётные данные приводят к ответу 401; исключение уже содержит код HTTP-ответа
@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 HttpServerResponseException.of(401, "Unauthorized") //(1)!
        }
        return chain.process(request)
    }
}
  1. Отсутствующие или некорректные учётные данные приводят к ответу 401; исключение уже содержит код HTTP-ответа

Далее перехватчик подключается через @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

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

Для метрик нужны httpServer.telemetry.metrics.enabled и MeterRegistry из модуля метрик. Сервер пишет таймер http.server.request.duration с бакетами из httpServer.telemetry.metrics.slo и тегами server.name, server.port, http.request.method, http.response.status_code, http.route, url.scheme и error.type, а также gauge http.server.active_requests с теми же тегами, кроме http.response.status_code и error.type; к обоим добавляется все, что объявлено в httpServer.telemetry.metrics.tags. Значения заголовка Host (server.address) в тегах метрик нет: его задает клиент и число значений не ограничено. Он остается атрибутом span, а добавить его в метрики можно через фабрику метрик. Метрики и трассировка описаны в разделе Справочник метрик.

Логирование

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

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

Запрос, завершившийся исключением, логируется логгером ответа на уровне WARN.

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

  • pathFull — true всегда логирует полный путь запроса (/users/42), false всегда логирует шаблон маршрута (/users/{id}); если не задано, используется шаблон, кроме уровня TRACE
  • maskHeaders / maskQueries — имена заголовков и параметров запроса, значения которых маскируются, см. Маскирование
  • maxRequestBodyLogSize / maxResponseBodyLogSize — тело большего размера логируется без содержимого
  • stacktrace — при true (по умолчанию) логирует стектрейс исключения при ошибке обработки запроса

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

<logger name="io.koraframework.http.server.common.HttpServer" level="DEBUG"/>

Маскирование

Значения заголовков из maskHeaders (по умолчанию: authorization, cookie, set-cookie) и параметров запроса из maskQueries заменяются результатом MaskingStrategy с тегом @Tag(HttpServerTelemetry.class), которая по умолчанию пишет ***. Имена сравниваются без учета регистра, а каждое значение повторяющегося заголовка или параметра маскируется отдельно. Чтобы изменить способ маскирования, зарегистрируйте свою стратегию с тем же тегом — она заменит стратегию по умолчанию. Подходит любая MaskingStrategy, в том числе встроенные стратегии модуля логирования:

@KoraApp
public interface Application extends UndertowPublicHttpServerModule {

    @Tag(HttpServerTelemetry.class)
    default MaskingStrategy httpServerMaskingStrategy() {
        return new MaskingKeepLast("***", 4);
    }
}
@KoraApp
interface Application : UndertowPublicHttpServerModule {

    @Tag(HttpServerTelemetry::class)
    fun httpServerMaskingStrategy(): MaskingStrategy = MaskingKeepLast("***", 4)
}

Тела запроса и ответа, логируемые на уровне TRACE, маскирует компонент DataMasker с тегом @Tag(HttpServerTelemetry.class), подходящий под формат тела, который выбирается по Content-Type: json для */json и *+json, xml для */xml и *+xml, form-urlencoded для application/x-www-form-urlencoded. Встроенные JsonDataMasker, XmlDataMasker и FormUrlencodedDataMasker (пакет io.koraframework.logging.common.masking.raw) принимают маскируемые поля в виде MaskingPathRules. По умолчанию маскировщиков тела нет, поэтому тело логируется как есть, пока вы не зарегистрируете маскировщик:

@KoraApp
public interface Application extends UndertowPublicHttpServerModule {

    @Tag(HttpServerTelemetry.class)
    default DataMasker httpServerJsonMasker() {
        return new JsonDataMasker(MaskingPathRules.builder() //(1)!
            .mask("password", new MaskingFull()) //(2)!
            .mask("card.number", new MaskingKeepLast("***", 4)) //(3)!
            .build());
    }
}
  1. Маскирует тела в формате JSON; XmlDataMasker и FormUrlencodedDataMasker создаются так же
  2. Имя поля маскирует это поле в любом месте тела
  3. Путь через точку маскирует поле, достижимое по этому пути от корня тела
@KoraApp
interface Application : UndertowPublicHttpServerModule {

    @Tag(HttpServerTelemetry::class)
    fun httpServerJsonMasker(): DataMasker =
        JsonDataMasker(MaskingPathRules.builder() //(1)!
            .mask("password", MaskingFull()) //(2)!
            .mask("card.number", MaskingKeepLast("***", 4)) //(3)!
            .build())
}
  1. Маскирует тела в формате JSON; XmlDataMasker и FormUrlencodedDataMasker создаются так же
  2. Имя поля маскирует это поле в любом месте тела
  3. Путь через точку маскирует поле, достижимое по этому пути от корня тела

Маскировщик проходит по сырым байтам, не строя документ, никогда не падает на поврежденных данных и маскирует все, начиная с места, где он потерял структуру. Его вывод по умолчанию ограничен 64KiB, остаток заменяется на <masked:truncated> (<!--masked:truncated--> для XML).

Свой логгер

Телеметрия собирается из компонентов графа, поэтому каждую ее часть заменяет ваш собственный компонент того же типа: DefaultHttpServerLoggerFactory для логов, DefaultHttpServerMetricsFactory для метрик, DefaultHttpServerBodyConverter для превращения тел в текст лога или вся HttpServerTelemetryFactory целиком. Свой логгер может использовать io.koraframework.http.common.telemetry.MaskingUtils.toMaskedString(...), чтобы маскировать заголовки и параметры запроса через MaskingStrategy:

@Component
public final class MyHttpServerLoggerFactory extends DefaultHttpServerLoggerFactory {

    public MyHttpServerLoggerFactory(@Tag(HttpServerTelemetry.class) MaskingStrategy maskingStrategy) {
        super(maskingStrategy);
    }

    @Override
    public DefaultHttpServerLogger create(DefaultHttpServerTelemetry.TelemetryContext context) {
        return super.create(context); //(1)!
    }
}
  1. Верните здесь свой подкласс DefaultHttpServerLogger, чтобы изменить формат или назначение записей
@Component
class MyHttpServerLoggerFactory(
    @Tag(HttpServerTelemetry::class) maskingStrategy: MaskingStrategy
) : DefaultHttpServerLoggerFactory(maskingStrategy) {

    override fun create(context: DefaultHttpServerTelemetry.TelemetryContext): DefaultHttpServerLoggerFactory.DefaultHttpServerLogger {
        return super.create(context) //(1)!
    }
}
  1. Верните здесь свой подкласс DefaultHttpServerLogger, чтобы изменить формат или назначение записей