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:
Модуль:
Зависимость build.gradle.kts:
Модуль:
UndertowPublicHttpServerModule поднимает два сервера: публичный для контроллеров приложения
и системный для проб и метрик.
Если приложению нужен только системный сервер, подключите UndertowSystemHttpServerModule.
Конфигурация¶
Основные параметры конфигурации HTTP-сервера:
httpServer {
port = 8080 //(1)!
system.port = 8085 //(2)!
maxRequestBodySize = "256MiB" //(3)!
telemetry.logging.enabled = false //(4)!
}
- Порт публичного
HTTPсервера (по умолчанию:8080) - Порт системного
HTTPсервера (по умолчанию:8085) - Максимально допустимый размер тела входящего запроса (по умолчанию:
256MiB) - Включает логирование запросов и ответов (по умолчанию:
false)
httpServer:
port: 8080 #(1)!
system:
port: 8085 #(2)!
maxRequestBodySize: "256MiB" #(3)!
telemetry:
logging:
enabled: false #(4)!
- Порт публичного
HTTPсервера (по умолчанию:8080) - Порт системного
HTTPсервера (по умолчанию:8085) - Максимально допустимый размер тела входящего запроса (по умолчанию:
256MiB) - Включает логирование запросов и ответов (по умолчанию:
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"
}
}
}
}
- Порт публичного
HTTPсервера (по умолчанию:8080) - Игнорировать ли завершающий
/в пути: при включении/my/pathи/my/path/считаются одним маршрутом (по умолчанию:false) - Время ожидания обработки запросов перед остановкой сервера при graceful shutdown (по умолчанию:
30s) - Максимальное время ожидания чтения данных из сокета или соединения,
0sотключает таймаут (по умолчанию:0s) - Максимальное время ожидания записи данных в сокет или соединение,
0sотключает таймаут (по умолчанию:0s) - Включать ли
TCP keep-aliveдля сокета или соединения (по умолчанию:false) - Всегда ли отправлять заголовок ответа
Connection: keep-alive(по умолчанию:false) - Всегда ли отправлять заголовок ответа
Date(по умолчанию:true) - Максимально допустимый размер тела входящего запроса (по умолчанию:
256MiB) - Включает логирование модуля (по умолчанию:
false) - Включает логирование стектрейса при исключении (по умолчанию:
true) - Маска, которой скрываются указанные заголовки и параметры запроса или ответа (по умолчанию:
***) - Список параметров запроса, которые надо скрыть (по умолчанию:
[]) - Список заголовков запроса или ответа, которые надо скрыть (по умолчанию:
[ "authorization", "cookie", "set-cookie" ]) - Логировать ли полный путь запроса вместо шаблона маршрута; если не указано, используется шаблон, а на уровне
TRACE— полный путь (по умолчанию не указано, опционально) - Максимальный размер тела запроса, который может быть записан в лог; тело большего размера логируется без содержимого (по умолчанию:
2MiB) - Максимальный размер тела ответа, который может быть записан в лог; тело большего размера логируется без содержимого (по умолчанию:
2MiB) - Включает метрики модуля (по умолчанию:
false) - Настраивает SLO для метрик (по умолчанию:
io.koraframework.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO) - Настраивает теги метрик (по умолчанию:
{}) - Включает трассировку модуля (по умолчанию:
true) - Записывать ли полный путь запроса в атрибут спана
url.path(по умолчанию:true) - Настраивает атрибуты трассировки (по умолчанию:
{})
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
- Порт публичного
HTTPсервера (по умолчанию:8080) - Игнорировать ли завершающий
/в пути: при включении/my/pathи/my/path/считаются одним маршрутом (по умолчанию:false) - Время ожидания обработки запросов перед остановкой сервера при graceful shutdown (по умолчанию:
30s) - Максимальное время ожидания чтения данных из сокета или соединения,
0sотключает таймаут (по умолчанию:0s) - Максимальное время ожидания записи данных в сокет или соединение,
0sотключает таймаут (по умолчанию:0s) - Включать ли
TCP keep-aliveдля сокета или соединения (по умолчанию:false) - Всегда ли отправлять заголовок ответа
Connection: keep-alive(по умолчанию:false) - Всегда ли отправлять заголовок ответа
Date(по умолчанию:true) - Максимально допустимый размер тела входящего запроса (по умолчанию:
256MiB) - Включает логирование модуля (по умолчанию:
false) - Включает логирование стектрейса при исключении (по умолчанию:
true) - Маска, которой скрываются указанные заголовки и параметры запроса или ответа (по умолчанию:
***) - Список параметров запроса, которые надо скрыть (по умолчанию:
[]) - Список заголовков запроса или ответа, которые надо скрыть (по умолчанию:
[ "authorization", "cookie", "set-cookie" ]) - Логировать ли полный путь запроса вместо шаблона маршрута; если не указано, используется шаблон, а на уровне
TRACE— полный путь (по умолчанию не указано, опционально) - Максимальный размер тела запроса, который может быть записан в лог; тело большего размера логируется без содержимого (по умолчанию:
2MiB) - Максимальный размер тела ответа, который может быть записан в лог; тело большего размера логируется без содержимого (по умолчанию:
2MiB) - Включает метрики модуля (по умолчанию:
false) - Настраивает SLO для метрик (по умолчанию:
io.koraframework.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO) - Настраивает теги метрик (по умолчанию:
{}) - Включает трассировку модуля (по умолчанию:
true) - Записывать ли полный путь запроса в атрибут спана
url.path(по умолчанию:true) - Настраивает атрибуты трассировки (по умолчанию:
{})
Метрики модуля описаны в разделе Справочник метрик.
Системный сервер¶
Системный сервер настраивается в собственной секции 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)!
}
- Порт системного
HTTPсервера (по умолчанию:8085) - Путь для получения метрик на системном сервере (по умолчанию:
/metrics) - Путь для получения статуса readiness пробы на системном сервере (по умолчанию:
/system/readiness) - Путь для получения статуса liveness пробы на системном сервере (по умолчанию:
/system/liveness) - Включает трассировку запросов системного сервера (по умолчанию:
false, в отличие от публичного сервера)
httpServer:
system:
port: 8085 #(1)!
metricsPath: "/metrics" #(2)!
readinessPath: "/system/readiness" #(3)!
livenessPath: "/system/liveness" #(4)!
telemetry:
tracing:
enabled: false #(5)!
- Порт системного
HTTPсервера (по умолчанию:8085) - Путь для получения метрик на системном сервере (по умолчанию:
/metrics) - Путь для получения статуса readiness пробы на системном сервере (по умолчанию:
/system/readiness) - Путь для получения статуса liveness пробы на системном сервере (по умолчанию:
/system/liveness) - Включает трассировку запросов системного сервера (по умолчанию:
false, в отличие от публичного сервера)
Undertow¶
Транспортные настройки самого Undertow вынесены в отдельную секцию httpServer.undertow и общие для обоих серверов,
поскольку настраивают единый XnioWorker:
- Количество потоков сетевого ввода-вывода (по умолчанию: количество доступных процессоров, но не меньше
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");
}
}
- Настраивает билдер
Undertowпубличного сервера до его старта - Оборачивает корневой
HttpHandlerпубличного сервера - Настраивает
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") }
}
- Настраивает билдер
Undertowпубличного сервера до его старта - Оборачивает корневой
HttpHandlerпубличного сервера - Настраивает
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";
}
}
- Указывает, что класс является компонентом и должен быть зарегистрирован в контейнере зависимостей приложения
- Указывает, что класс является контроллером и содержит HTTP-обработчики
- Указывает, что метод является обработчиком пути в контроллере
- Указывает тип
HTTPметода обработчика - Указывает путь метода-обработчика
@Component //(1)!
@HttpController //(2)!
class SomeController {
//(3)!
@HttpRoute(method = HttpMethod.POST, //(4)!
path = "/hello/world") //(5)!
fun helloWorld(): String {
return "Hello World"
}
}
- Указывает, что класс является компонентом и должен быть зарегистрирован в контейнере зависимостей приложения
- Указывает, что класс является контроллером и содержит HTTP-обработчики
- Указывает, что метод является обработчиком пути в контроллере
- Указывает тип
HTTPметода обработчика - Указывает путь метода-обработчика
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 (см. Конфигурация):
Сопоставление метода. Путь, обслуживаемый методом без подходящего маршрута, вернёт 405 Method Not Allowed; неизвестный путь вернёт 404 Not Found.
Сопоставленный шаблон доступен во время выполнения как HttpServerRequest.route() и используется как метка пути с низкой кардинальностью в метриках и трассировке (см. Телеметрия).
Запрос¶
В этом разделе описано, как HTTP запрос превращается в аргументы метода контроллера.
Для частей запроса используются специальные аннотации, а тело запроса передается аргументом без такой аннотации.
Конвертация строковых параметров¶
Значения из пути, параметров запроса, заголовков и cookie приходят строками.
Для преобразования строки в целевой тип Kora использует HttpServerParameterReader<T>:
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.
После регистрации конвертера пользовательский тип можно использовать в параметрах контроллера:
@HttpRoute(method = HttpMethod.GET, path = "/users/{id}")
public User get(@Path("id") UserId id) {
return userService.get(id);
}
Параметр пути¶
@Path — обозначает значение части пути запроса, сам параметр указывается в пути как {path},
а имя параметра задается в value либо по умолчанию равно имени аргумента метода.
Значение преобразуется через HttpServerParameterReader<T>, поэтому доступны как встроенные, так и пользовательские типы.
Параметр пути всегда обязателен: если указанное в @Path имя отсутствует в шаблоне маршрута,
компиляция завершается ошибкой Path parameter '...' is not present in the request mapping path.
Параметр запроса¶
@Query — значение параметра запроса, имя параметра задается в value либо по умолчанию равно имени аргумента метода.
Поддерживаются одиночные значения, List<T> и Set<T>. List<T> сохраняет все значения параметра,
а Set<T> убирает дубликаты и сохраняет порядок первого вхождения.
Параметр без значения (/hello/world?queryName) считается отсутствующим:
обязательный параметр приведет к ответу 400 с сообщением Query parameter 'queryName' is required.
Заголовок запроса¶
@Header — значение заголовка запроса, имя параметра задается в value либо по умолчанию равно имени аргумента метода.
Поддерживаются одиночные значения, List<T> и Set<T>. List<T> и Set<T> используют все значения заголовка.
Тело запроса¶
Для указания тела запроса используется аргумент метода без специальных аннотаций.
По умолчанию поддерживаются byte[], ByteBuffer, String, InputStream, HttpBodyInput, FormUrlEncoded, FormMultipart,
а также пользовательские типы через HttpServerRequestMapper<T>.
InputStream и HttpBodyInput дают доступ к телу без буферизации его в памяти, что удобно для больших загрузок.
JSON¶
Для указания, что тело является JSON и для него требуется внедрить JsonReader<T>, используется аннотация @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с таким именем, либоnullFormPart.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;
}
}
-
Читает поле
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"
}
}
-
Читает поле
nameиз отправленной формы -
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";
}
}
- Текстовое поле формы
-
Файл, загруженный в форме
-
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"
}
}
FormMultipart.parts()возвращает запечатанныйFormPart:MultipartData,MultipartFileлибоMultipartFileStream
Cookie¶
@Cookie — значение Cookie, имя параметра задается в value либо по умолчанию равно имени аргумента метода.
Значение можно получить как String, как тип Cookie с именем, значением и атрибутами, либо как другой тип через HttpServerParameterReader<T>.
Пользовательский параметр¶
Если аргумент метода нужно собрать из запроса вручную, используется интерфейс 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";
}
}
- Сгенерированный модуль контроллера внедряет маппер как зависимость, поэтому класс маппера обязан быть компонентом графа
@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"
}
}
- Сгенерированный модуль контроллера внедряет маппер как зависимость, поэтому класс маппера обязан быть компонентом графа
No component found for dependency
Класс маппера, указанный в @Mapping, никогда не создается сгенерированным модулем — он запрашивается из контейнера.
Забытая аннотация @Component приводит к ошибке сборки графа:
То же самое относится к классам перехватчиков из @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()));
}
}
HttpHeadersс методамиgetFirstиgetAllMap<String, List<String>>параметров запроса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()))
}
}
HttpHeadersс методамиgetFirstиgetAllMap<String, List<String>>параметров запроса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";
}
}
- Kora и примеры используют
org.jspecify.annotations.Nullable; подойдет любая аннотация с простым именемNullable.
Используйте синтаксис Kotlin Nullability и отметьте такой параметр как необязательный:
Ответ¶
По умолчанию можно использовать стандартные типы возвращаемых значений: 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)!
);
}
}
- Статус-код
HTTPответа - Заголовки ответа
- Тело ответа
@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)!
)
}
}
- Статус-код
HTTPответа - Заголовки ответа
- Тело ответа
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");
}
}
- Указывает, что ответ должен быть в формате
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.
Пользовательский ответ¶
Если ответ нужно формировать особым образом, используется интерфейс 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");
}
}
- Как и мапперы запроса, класс внедряется в сгенерированный модуль и обязан быть компонентом графа
@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")
}
}
- Как и мапперы запроса, класс внедряется в сгенерированный модуль и обязан быть компонентом графа
- Контракт объявляет результат 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";
}
}
- Префикс пути, применяемый ко всем маршрутам контроллера
- Маршрут с параметром пути, итоговый маршрут —
/api/v1/pets/{id} - Маршрут с завершающим шаблоном, итоговый маршрут —
/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"
}
- Префикс пути, применяемый ко всем маршрутам контроллера
- Маршрут с параметром пути, итоговый маршрут —
/api/v1/pets/{id} - Маршрут с завершающим шаблоном, итоговый маршрут —
/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(): TmyMethod(): 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";
}
}
- Перехватывает все маршруты этого контроллера
- Перехватывает все маршруты публичного сервера, включая ответы
404и405 - Перехватывает только этот маршрут
@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"
}
}
- Перехватывает все маршруты этого контроллера
- Перехватывает все маршруты публичного сервера, включая ответы
404и405 - Перехватывает только этот маршрут
Тег глобального перехватчика
Фреймворк собирает глобальные перехватчики только по тегу @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);
}
}
}
}
- У перехватчика есть зависимость в конструкторе, поэтому он обязан быть компонентом графа
HttpServerResponseExceptionсам является ответом, поэтому он возвращается в том виде, в каком его должен увидеть клиент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)
}
}
}
}
}
- У перехватчика есть зависимость в конструкторе, поэтому он обязан быть компонентом графа
HttpServerResponseExceptionсам является ответом, поэтому он возвращается в том виде, в каком его должен увидеть клиент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"));
});
}
}
- Указывает тип
HTTPметода обработчика - Указывает путь метода-обработчика
@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"))
}
}
}
- Указывает тип
HTTPметода обработчика - Указывает путь метода-обработчика
У 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 с полями или без них:
Чтобы передавать дополнительную информацию об авторизации (userId, роли, scope), создайте свою реализацию Principal:
Если требуется работа со scope, используется интерфейс 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");
};
}
}
- Тег назван по имени схемы безопасности из контракта
- Возврат
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")
}
}
}
}
- Тег назван по имени схемы безопасности из контракта
- Возврат
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");
}
}
- Возвращает
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")
}
}
}
- Возвращает
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)!
}
}
- Привязывает 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)!
}
}
- Привязывает principal к текущему запросу, чтобы дальше по цепочке
Principal.current()возвращал его
Интеграция с OpenAPI¶
При использовании генератора OpenAPI авторизация настраивается автоматически на основе OpenAPI-спецификации. Генератор создает:
- Интерфейс
ApiSecurityс классом-маркером для каждой схемы безопасности, названным по имени схемы (ApiKeyAuth,BearerAuth,BasicAuth,CookieAuth,OAuth) HttpServerInterceptorдля каждого требования безопасности, применяемый к сгенерированному контроллеру- Требование предоставить
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:
@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:
Конфигурация:
Если одна операция требует сразу несколько схем, тег экстрактора склеивает имена схем через 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);
}
}
- Исключение превращается в
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)
}
}
- Исключение превращается в
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)!
}
- На уровне
INFOлогируется только операция,DEBUGдобавляет заголовки и параметры запроса TRACEдополнительно пишет тело, ограниченноеmaxRequestBodyLogSize/maxResponseBodyLogSize
logging:
levels:
"io.koraframework.http.server.common.HttpServer.request": "DEBUG" #(1)!
"io.koraframework.http.server.common.HttpServer.response": "TRACE" #(2)!
- На уровне
INFOлогируется только операция,DEBUGдобавляет заголовки и параметры запроса 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-параметров, значения которых заменяются наmaskmask— строка замены (по умолчанию***)stacktrace— приtrue(по умолчанию) логирует стектрейс исключения при ошибке обработки запроса
Пример конфигурации logback, включающей логирование заголовков сервера:
Свой логгер¶
Чтобы полностью управлять форматом или назначением лога, предоставьте свой компонент HttpServerLoggerFactory (или HttpServerLogger) — он заменит
фабрику по умолчанию Slf4jHttpServerLoggerFactory. То же касается метрик (HttpServerMetricsFactory) и трассировки (HttpServerTracerFactory):
предоставление любого из этих компонентов переопределяет соответствующую реализацию по умолчанию, остальные сохраняют реализацию по умолчанию.