HTTP сервер
Модуль HTTP-сервера описывает входящую HTTP-границу приложения: прием запроса, разбор параметров, чтение тела,
выбор обработчика, формирование ответа, телеметрию и перехватчики. В Kora можно описывать контроллеры декларативно
через @HttpController и @HttpRoute как тонкий слой абстракции, либо регистрировать обработчики императивно через HttpServerRequestHandler.
Декларативный подход подходит для большинства API: сигнатура метода описывает HTTP-контракт, а Kora во время компиляции создает обработчик. Императивный подход полезен для низкоуровневых или динамических маршрутов, где запрос удобнее обрабатывать вручную.
Совет
Мы советуем использовать подход, при котором первичен контракт в формате OpenAPI,
а контроллеры создаются с помощью генератора.
Такой подход помогает сохранить согласованность контракта между потребителем и владельцем контракта
и позволяет использовать тот же контракт для генерации клиентов.
Подробнее про генератор смотрите в разделе про генерацию из OpenAPI.
Если нужен пошаговый разбор перед справочным описанием, смотрите HTTP-сервер и продвинутый HTTP-сервер.
Подключение¶
Реализация основана на Undertow.
Undertow — это легковесный веб-сервер с открытым исходным кодом для Java-приложений.
Он построен на асинхронных и неблокирующих операциях ввода-вывода с использованием NIO,
что обеспечивает высокую производительность и низкое потребление ресурсов.
Зависимость build.gradle:
Модуль:
Зависимость build.gradle.kts:
Модуль:
Конфигурация¶
Основные параметры конфигурации HTTP-сервера:
httpServer {
publicApiHttpPort = 8080 //(1)!
privateApiHttpPort = 8085 //(2)!
virtualThreadsEnabled = false //(3)!
maxRequestBodySize = "256MiB" //(4)!
}
- Порт публичного
HTTP-сервера (по умолчанию:8080) - Порт служебного
HTTP-сервера (по умолчанию:8085) - Включает виртуальные потоки для блокирующей обработки запросов вместо пула
blockingThreads, требуетJava 21+(по умолчанию:false) - Максимально допустимый размер тела входящего запроса (по умолчанию:
256MiB)
httpServer:
publicApiHttpPort: 8080 #(1)!
privateApiHttpPort: 8085 #(2)!
virtualThreadsEnabled: false #(3)!
maxRequestBodySize: "256MiB" #(4)!
- Порт публичного
HTTP-сервера (по умолчанию:8080) - Порт служебного
HTTP-сервера (по умолчанию:8085) - Включает виртуальные потоки для блокирующей обработки запросов вместо пула
blockingThreads, требуетJava 21+(по умолчанию:false) - Максимально допустимый размер тела входящего запроса (по умолчанию:
256MiB)
Полная конфигурация
Пример полной конфигурации, описанной в классе HttpServerConfig (указаны примеры значений или значения по умолчанию):
httpServer {
publicApiHttpPort = 8080 //(1)!
privateApiHttpPort = 8085 //(2)!
privateApiHttpMetricsPath = "/metrics" //(3)!
privateApiHttpReadinessPath = "/system/readiness" //(4)!
privateApiHttpLivenessPath = "/system/liveness" //(5)!
ignoreTrailingSlash = false //(6)!
ioThreads = 2 //(7)!
blockingThreads = 2 //(8)!
shutdownWait = "30s" //(9)!
threadKeepAliveTimeout = "60s" //(10)!
socketReadTimeout = "0s" //(11)!
socketWriteTimeout = "0s" //(12)!
socketKeepAliveEnabled = false //(13)!
virtualThreadsEnabled = false //(14)!
maxRequestBodySize = "256MiB" //(15)!
telemetry {
logging {
enabled = false //(16)!
stacktrace = true //(17)!
mask = "***" //(18)!
maskQueries = [ ] //(19)!
maskHeaders = [ "authorization", "cookie", "set-cookie" ] //(20)!
pathTemplate = true //(21)!
}
metrics {
enabled = true //(22)!
slo = [ 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000 ] //(23)!
tags = { // (24)!
"key1" = "value1"
"key2" = "value2"
}
}
tracing {
enabled = true //(25)!
attributes = { // (26)!
"key1" = "value1"
"key2" = "value2"
}
}
}
}
- Порт публичного
HTTP-сервера (по умолчанию:8080) - Порт служебного
HTTP-сервера (по умолчанию:8085) - Путь для получения метрик на служебном сервере (по умолчанию:
/metrics) - Путь для получения статуса проб готовности на служебном сервере (по умолчанию:
/system/readiness) - Путь для получения статуса проб жизнеспособности на служебном сервере (по умолчанию:
/system/liveness) - Игнорировать ли завершающий
/в пути: если включено,/my/pathи/my/path/будут считаться одним маршрутом (по умолчанию:false) - Количество потоков сетевого ввода-вывода (по умолчанию: количество доступных процессоров, но не меньше
2) - Количество потоков для блокирующей обработки запросов (по умолчанию:
min(max(доступные процессоры, 2) * 8, 200)) - Время ожидания обработки перед выключением сервера при штатном завершении (по умолчанию:
30s) - Максимальное время жизни потока обработчика запроса без работы (по умолчанию:
60s) - Максимальное время ожидания чтения данных из сокета или соединения;
0sотключает тайм-аут (по умолчанию:0s) - Максимальное время ожидания записи данных в сокет или соединение;
0sотключает тайм-аут (по умолчанию:0s) - Включать ли
TCP keep-aliveдля сокета или соединения (по умолчанию:false) - Включает виртуальные потоки для блокирующей обработки запросов вместо пула
blockingThreads, требуетJava 21+(по умолчанию:false) - Максимально допустимый размер тела входящего запроса (по умолчанию:
256MiB) - Включает логирование модуля (по умолчанию:
false) - Включает логирование стека вызовов при исключении (по умолчанию:
true) - Маска, которая используется для скрытия указанных заголовков и параметров запроса или ответа (по умолчанию:
***) - Список параметров запроса, которые следует скрывать (по умолчанию:
[]) - Список заголовков запроса или ответа, которые следует скрывать (по умолчанию:
[ "authorization", "cookie", "set-cookie" ]) - Использовать ли шаблон пути запроса при логировании; если не указано, шаблон используется всегда, кроме уровня
TRACE, где используется полный путь (по умолчанию не указано, необязательно) - Включает метрики модуля (по умолчанию:
true) - Настройка SLO для метрик (по умолчанию:
ru.tinkoff.kora.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO) - Настройка тегов для метрик (по умолчанию:
{}) - Включает трассировку модуля (по умолчанию:
true) - Настройка атрибутов для трассировки (по умолчанию:
{})
httpServer:
publicApiHttpPort: 8080 #(1)!
privateApiHttpPort: 8085 #(2)!
privateApiHttpMetricsPath: "/metrics" #(3)!
privateApiHttpReadinessPath: "/system/readiness" #(4)!
privateApiHttpLivenessPath: "/system/liveness" #(5)!
ignoreTrailingSlash: false #(6)!
ioThreads: 2 #(7)!
blockingThreads: 2 #(8)!
shutdownWait: "30s" #(9)!
threadKeepAliveTimeout: "60s" #(10)!
socketReadTimeout: "0s" #(11)!
socketWriteTimeout: "0s" #(12)!
socketKeepAliveEnabled: false #(13)!
virtualThreadsEnabled: false #(14)!
maxRequestBodySize: "256MiB" #(15)!
telemetry:
logging:
enabled: false #(16)!
stacktrace: true #(17)!
mask: "***" #(18)!
maskQueries: [ ] #(19)!
maskHeaders: [ "authorization", "cookie", "set-cookie" ] #(20)!
pathTemplate: true #(21)!
metrics:
enabled: true #(22)!
slo: [ 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 90000 ] #(23)!
tags: #(24)!
key1: value1
key2: value2
tracing:
enabled: true #(25)!
attributes: #(26)!
key1: value1
key2: value2
- Порт публичного
HTTP-сервера (по умолчанию:8080) - Порт служебного
HTTP-сервера (по умолчанию:8085) - Путь для получения метрик на служебном сервере (по умолчанию:
/metrics) - Путь для получения статуса проб готовности на служебном сервере (по умолчанию:
/system/readiness) - Путь для получения статуса проб жизнеспособности на служебном сервере (по умолчанию:
/system/liveness) - Игнорировать ли завершающий
/в пути: если включено,/my/pathи/my/path/будут считаться одним маршрутом (по умолчанию:false) - Количество потоков сетевого ввода-вывода (по умолчанию: количество доступных процессоров, но не меньше
2) - Количество потоков для блокирующей обработки запросов (по умолчанию:
min(max(доступные процессоры, 2) * 8, 200)) - Время ожидания обработки перед выключением сервера при штатном завершении (по умолчанию:
30s) - Максимальное время жизни потока обработчика запроса без работы (по умолчанию:
60s) - Максимальное время ожидания чтения данных из сокета или соединения;
0sотключает тайм-аут (по умолчанию:0s) - Максимальное время ожидания записи данных в сокет или соединение;
0sотключает тайм-аут (по умолчанию:0s) - Включать ли
TCP keep-aliveдля сокета или соединения (по умолчанию:false) - Включает виртуальные потоки для блокирующей обработки запросов вместо пула
blockingThreads, требуетJava 21+(по умолчанию:false) - Максимально допустимый размер тела входящего запроса (по умолчанию:
256MiB) - Включает логирование модуля (по умолчанию:
false) - Включает логирование стека вызовов при исключении (по умолчанию:
true) - Маска, которая используется для скрытия указанных заголовков и параметров запроса или ответа (по умолчанию:
***) - Список параметров запроса, которые следует скрывать (по умолчанию:
[]) - Список заголовков запроса или ответа, которые следует скрывать (по умолчанию:
[ "authorization", "cookie", "set-cookie" ]) - Использовать ли шаблон пути запроса при логировании; если не указано, шаблон используется всегда, кроме уровня
TRACE, где используется полный путь (по умолчанию не указано, необязательно) - Включает метрики модуля (по умолчанию:
true) - Настройка SLO для метрик (по умолчанию:
ru.tinkoff.kora.telemetry.common.TelemetryConfig.MetricsConfig#DEFAULT_SLO) - Настройка тегов для метрик (по умолчанию:
{}) - Включает трассировку модуля (по умолчанию:
true) - Настройка атрибутов для трассировки (по умолчанию:
{})
Предоставляемые метрики модуля описаны в разделе Справочник метрик.
Kora предоставляет тонкую настройку HTTP-сервера Undertow через два специализированных интерфейса конфигурации: UndertowConfigurer и HttpHandlerConfigurer.
Они позволяют настраивать поведение сервера и конвейер обработки запросов, не жертвуя интеграцией с модульной архитектурой Kora.
Контроллер декларативный¶
Для создания контроллера следует использовать @HttpController аннотацию, а для его регистрации как зависимость @Component.
Аннотация @HttpRoute отвечает за указания пути и метода HTTP для конкретного метода обработчика.
@Component //(1)!
@HttpController //(2)!
public final class SomeController {
//(3)!
@HttpRoute(method = HttpMethod.POST, //(4)!
path = "/hello/world") //(5)!
public String helloWorld() {
return "Hello World";
}
}
- Указывает что класс является компонентом и его требуется зарегистрировать в контейнере приложения
- Указывает что класс является контроллером и содержит 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-метода обработчика - Указывает путь метода обработчика
Запрос¶
Раздел описывает преобразование HTTP-запроса в аргументы метода контроллера.
Для частей запроса используются специальные аннотации, а тело запроса передается аргументом без такой аннотации.
Преобразование параметров из строки¶
Значения из пути, параметров запроса, заголовков и cookie приходят как строки.
Для преобразования строки в нужный тип Kora использует StringParameterReader<T>:
StringParameterReader<T> ищется как компонент графа по точному типу параметра. Если параметр объявлен как List<T> или Set<T>,
преобразователь применяется к каждому значению отдельно.
Из коробки поддерживаются String, Boolean, Integer, Long, Float, Double, UUID, BigInteger, BigDecimal,
Duration, LocalDate, LocalTime, LocalDateTime, OffsetTime, OffsetDateTime, ZonedDateTime и enum.
Для enum по умолчанию используется имя значения через Enum.name(). Если значение невозможно преобразовать, запрос завершается
ответом 400 через HttpServerResponseException.
После регистрации преобразователя пользовательский тип можно использовать в параметрах контроллера:
@HttpRoute(method = HttpMethod.GET, path = "/users/{id}")
public User get(@Path("id") UserId id) {
return userService.get(id);
}
Параметр пути¶
@Path — обозначает значение части пути запроса, сам параметр указывается в {кавычках} в пути
и имя параметра указывается в value либо по умолчанию равно имени аргумента метода.
Значение преобразуется через StringParameterReader<T>, поэтому можно использовать как встроенные типы, так и пользовательские.
Параметр запроса¶
@Query — значение параметра запроса, имя параметра указывается в value либо по умолчанию равно имени аргумента метода.
Поддерживаются одиночные значения, List<T> и Set<T>. Для List<T> сохраняются все значения параметра,
для Set<T> повторяющиеся значения удаляются с сохранением порядка первого появления.
Заголовок запроса¶
@Header — значение заголовка запроса, имя параметра указывается в value либо по умолчанию равно имени аргумента метода.
Поддерживаются одиночные значения, List<T> и Set<T>.
Для List<T> и Set<T> используются все значения заголовка.
Тело запроса¶
Для указания тела запроса требуется использовать аргумент метода без специальных аннотаций.
По умолчанию поддерживаются byte[], ByteBuffer, String, FormUrlEncoded, FormMultipart и пользовательские типы через HttpServerRequestMapper<T>.
JSON¶
Для указания, что тело является JSON и для него требуется автоматически создать и внедрить JsonReader<T>,
используется аннотация @Json:
Требуется подключить модуль JSON.
Текстовая форма¶
Можно использовать FormUrlEncoded как тип аргумента тела форма данных.
Бинарная форма¶
Можно использовать FormMultipart как тип аргумента тела бинарная форма.
Куки¶
@Cookie — значение Cookie, имя параметра указывается в value либо по умолчанию равно имени аргумента метода.
Можно получить значение как String, как тип Cookie с именем, значением и атрибутами, либо как другой тип через StringParameterReader<T>.
Пользовательский параметр¶
Если требуется собрать аргумент метода из запроса вручную, можно использовать специальный интерфейс HttpServerRequestMapper<T>.
Такой подход удобен для пользовательского контекста, авторизации, сложной проверки заголовков или нескольких частей запроса сразу:
@Component
@HttpController
public final class SomeController {
public record UserContext(String userId, String traceId) {}
public static final class RequestMapper implements HttpServerRequestMapper<UserContext> {
@Override
public UserContext apply(HttpServerRequest request) {
return new UserContext(request.headers().getFirst("x-user-id"), request.headers().getFirst("x-trace-id"));
}
}
@HttpRoute(method = HttpMethod.POST, path = "/hello/world")
public String get(@Mapping(RequestMapper.class) UserContext context) {
return "Hello World";
}
}
@Component
@HttpController
class MapperRequestController {
data class UserContext(val userId: String, val traceId: String)
class RequestMapper : HttpServerRequestMapper<UserContext> {
override fun apply(request: HttpServerRequest): UserContext {
return UserContext(
request.headers().getFirst("x-user-id"),
request.headers().getFirst("x-trace-id")
)
}
}
@HttpRoute(method = HttpMethod.POST, path = "/hello/world")
@Mapping(RequestMapper::class)
operator fun get(@Mapping(RequestMapper::class) context: UserContext): String {
return "Hello World"
}
}
Обязательные параметры¶
По умолчанию все аргументы, объявленные в методе, являются обязательными.
Если обязательное значение отсутствует в запросе, Kora вернет ответ 400.
По умолчанию все аргументы метода, которые не используют синтаксис Kotlin Nullability,
считаются обязательными. Если обязательное значение отсутствует в запросе, Kora вернет ответ 400.
Необязательные параметры¶
Если аргумент метода является необязательным, то есть может отсутствовать в запросе,
можно использовать аннотацию @Nullable или Optional<T> для одиночных значений:
@Component
@HttpController
public final class SomeController {
@HttpRoute(method = HttpMethod.POST, path = "/hello/world")
public String helloWorld(@Nullable @Query("queryName") String queryValue) { //(1)!
return "Hello World";
}
}
- Подойдет любая аннотация
@Nullable, напримерjavax.annotation.Nullable,jakarta.annotation.Nullableилиorg.jetbrains.annotations.Nullable.
Предполагается использовать синтаксис Kotlin Nullability и помечать такой параметр как необязательный:
Ответ¶
По умолчанию можно использовать стандартные типы возвращаемых значений: byte[], ByteBuffer, String.
Они будут обработаны со статусом 200 и соответствующим заголовком типа ответа.
Если нужно вручную указать статус, заголовки или тело, метод может вернуть HttpServerResponse.
Основной контракт HttpServerResponse состоит из кода ответа, заголовков и необязательного тела:
public interface HttpServerResponse {
int code();
MutableHttpHeaders headers();
@Nullable
HttpBodyOutput body();
}
@Component
@HttpController
public final class SomeController {
@HttpRoute(method = HttpMethod.POST, path = "/hello/world")
public HttpServerResponse helloWorld() {
return HttpServerResponse.of(
200, //(1)!
HttpHeaders.of("headerName", "headerValue"), //(2)!
HttpBody.plaintext("Hello World") //(3)!
);
}
}
- Код состояния
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-ответа - Заголовки ответа
- Тело ответа
JSON¶
Если предполагается отвечать в формате JSON, требуется использовать аннотацию @Json над методом.
Для типа ответа Kora найдет или создаст JsonWriter<T>:
Требуется подключить модуль JSON.
Сущность ответа¶
Если требуется вернуть тело, заголовки и код состояния ответа вместе,
используется HttpResponseEntity<T> — обертка над телом ответа.
Ниже показан пример, аналогичный примеру JSON, вместе с оберткой HttpResponseEntity:
@Component
@HttpController
public final class SomeController {
public record Response(String greeting) {}
@Json
@HttpRoute(method = HttpMethod.POST, path = "/hello/world")
public HttpResponseEntity<Response> helloWorld() {
return HttpResponseEntity.of(200, HttpHeaders.of("myHeader", "12345"), new Response("Hello World"));
}
}
@Component
@HttpController
class SomeController {
data class Response(val greeting: String)
@Json
@HttpRoute(method = HttpMethod.POST, path = "/hello/world")
fun helloWorld(): HttpResponseEntity<Response> {
return HttpResponseEntity.of(200, HttpHeaders.of("myHeader", "12345"), Response("Hello World"));
}
}
Ответ исключение¶
Если требуется прервать обработку и сразу вернуть ошибку, можно бросить HttpServerResponseException.
Это одновременно исключение и HttpServerResponse, поэтому его можно выбросить из контроллера, сервиса или преобразователя параметра.
Фабричные методы HttpServerResponseException.of(...) позволяют указать код состояния, текст ответа, причину и заголовки.
Тело ответа будет записано как text/plain; charset=utf-8.
Пользовательский ответ¶
Если требуется сформировать ответ нестандартным способом, можно использовать специальный интерфейс HttpServerResponseMapper<T>.
Он получает Context, исходный HttpServerRequest и результат метода контроллера, а возвращает готовый HttpServerResponse:
@Component
@HttpController
public final class SomeController {
public record HelloWorldResponse(String greeting, String name) {}
public static final class ResponseMapper implements HttpServerResponseMapper<HelloWorldResponse> {
@Override
public HttpServerResponse apply(Context ctx, HttpServerRequest request, HelloWorldResponse result) {
return HttpServerResponse.of(200, HttpBody.plaintext(result.greeting() + " - " + result.name()));
}
}
@Mapping(ResponseMapper.class)
@HttpRoute(method = HttpMethod.POST, path = "/hello/world")
public HelloWorldResponse helloWorld() {
return new HelloWorldResponse("Hello World", "Bob");
}
}
@Component
@HttpController
class SomeController {
data class HelloWorldResponse(val greeting: String, val name: String)
class ResponseMapper : HttpServerResponseMapper<HelloWorldResponse> {
fun apply(ctx: Context, request: HttpServerRequest, result: HelloWorldResponse): HttpServerResponse {
return HttpServerResponse.of(200, HttpBody.plaintext(result.greeting + " - " + result.name))
}
}
@Mapping(ResponseMapper::class)
@HttpRoute(method = HttpMethod.POST, path = "/hello/world")
fun helloWorld(): HelloWorldResponse {
return HelloWorldResponse("Hello World", "Bob")
}
}
Сигнатуры¶
Доступные сигнатуры для методов декларативного HTTP обработчика из коробки:
Под T подразумевается тип возвращаемого значения, либо Void.
T myMethod()CompletionStage<T> myMethod()CompletionStageMono<T> myMethod()Project Reactor (надо подключить зависимость)
Под T подразумевается тип возвращаемого значения, либо Unit.
myMethod(): Tsuspend myMethod(): TKotlin Coroutine (надо подключить зависимость какimplementation)
Перехватчики¶
Можно создавать перехватчики для изменения поведения или добавления общей логики вокруг обработки запроса.
Для этого используется интерфейс HttpServerInterceptor:
public interface HttpServerInterceptor {
CompletionStage<HttpServerResponse> intercept(Context context, HttpServerRequest request, InterceptChain chain) throws Exception;
interface InterceptChain {
CompletionStage<HttpServerResponse> process(Context ctx, HttpServerRequest request) throws Exception;
}
}
Перехватчик получает текущий Context, HttpServerRequest и цепочку дальнейшей обработки.
Чтобы передать запрос дальше, нужно вызвать chain.process(context, request). Если перехватчик возвращает ответ сам,
обработчик контроллера дальше не вызывается.
Перехватчики можно использовать на:
- Конкретных методах контроллера
- Контроллере целиком
- Всех контроллерах сразу: для этого компонент перехватчика должен быть зарегистрирован с тегом
@Tag(HttpServerModule.class); таких глобальных перехватчиков может быть несколько
@Component
@HttpController
public final class SomeController {
public static final class MethodInterceptor implements HttpServerInterceptor {
@Override
public CompletionStage<HttpServerResponse> intercept(Context context,
HttpServerRequest request,
InterceptChain chain) throws Exception {
return chain.process(context, request);
}
}
@InterceptWith(MethodInterceptor.class)
@HttpRoute(method = HttpMethod.POST, path = "/intercepted")
public String helloWorld() {
return "Hello World";
}
}
@Component
@HttpController
class SomeController {
class MethodInterceptor : HttpServerInterceptor {
override fun intercept(
context: Context,
request: HttpServerRequest,
chain: HttpServerInterceptor.InterceptChain
): CompletionStage<HttpServerResponse> {
return chain.process(context, request)
}
}
@InterceptWith(MethodInterceptor::class)
@HttpRoute(method = HttpMethod.POST, path = "/intercepted")
fun helloWorld(): String {
return "Hello World"
}
}
Обработка ошибок¶
Обработка ошибок на уровне всех HTTP-ответов может быть реализована через перехватчик.
Ниже представлен простой пример такого перехватчика.
@Tag(HttpServerModule.class)
@Component
public final class ErrorInterceptor implements HttpServerInterceptor {
@Override
public CompletionStage<HttpServerResponse> intercept(Context context,
HttpServerRequest request,
InterceptChain chain) throws Exception {
return chain.process(context, request).exceptionally(e -> {
if(e instanceof CompletionException) {
e = e.getCause();
}
if (e instanceof HttpServerResponseException ex) {
return ex;
}
var body = HttpBody.plaintext(e.getMessage());
if (e instanceof IllegalArgumentException) {
return HttpServerResponse.of(400, body);
} else if (e instanceof TimeoutException) {
return HttpServerResponse.of(408, body);
} else {
return HttpServerResponse.of(500, body);
}
});
}
}
@Tag(HttpServerModule.class)
@Component
class ErrorInterceptor : HttpServerInterceptor {
override fun intercept(
context: Context,
request: HttpServerRequest,
chain: HttpServerInterceptor.InterceptChain
): CompletionStage<HttpServerResponse> {
return chain.process(context, request).exceptionally { e ->
val error = if (e is CompletionException) e.cause!! else e
if (error is HttpServerResponseException) {
return@exceptionally error
}
val body = HttpBody.plaintext(error.message)
when (error) {
is IllegalArgumentException -> HttpServerResponse.of(400, body)
is TimeoutException -> HttpServerResponse.of(408, body)
else -> HttpServerResponse.of(500, body)
}
}
}
}
Контроллер императивный¶
Для создания контроллера следует реализовать HttpServerRequestHandler.HandlerFunction интерфейс,
а затем зарегистрировать его в обработчике HttpServerRequestHandler.
Ниже показан пример по обработке всех описанных декларативных параметров запроса из примеров выше:
public interface SomeModule {
default HttpServerRequestHandler someHttpHandler() {
return HttpServerRequestHandlerImpl.of(HttpMethod.POST, //(1)!
"/hello/{world}", //(2)!
(context, request) -> {
var path = RequestHandlerUtils.parseStringPathParameter(request, "world");
var query = RequestHandlerUtils.parseOptionalStringQueryParameter(request, "query");
var queries = RequestHandlerUtils.parseOptionalStringListQueryParameter(request, "Queries");
var header = RequestHandlerUtils.parseOptionalStringHeaderParameter(request, "header");
var headers = RequestHandlerUtils.parseOptionalStringListHeaderParameter(request, "Headers");
return CompletableFuture.completedFuture(HttpServerResponse.of(200, HttpBody.plaintext("Hello World")));
});
}
}
- Указывает тип
HTTP-метода обработчика - Указывает путь метода обработчика
interface SomeModule {
fun someHttpHandler(): HttpServerRequestHandler? {
return HttpServerRequestHandlerImpl.of(
HttpMethod.POST, //(1)!
"/hello/{world}" //(2)!
) { context: Context, request: HttpServerRequest ->
val path = RequestHandlerUtils.parseStringPathParameter(request, "world")
val query = RequestHandlerUtils.parseOptionalStringQueryParameter(request, "query")
val queries = RequestHandlerUtils.parseOptionalStringListQueryParameter(request, "Queries")
val header = RequestHandlerUtils.parseOptionalStringHeaderParameter(request, "header")
val headers = RequestHandlerUtils.parseOptionalStringListHeaderParameter(request, "Headers")
CompletableFuture.completedFuture(HttpServerResponse.of(200, HttpBody.plaintext("Hello World")))
}
}
}
- Указывает тип
HTTP-метода обработчика - Указывает путь метода обработчика
Авторизация¶
Kora предоставляет механизм извлечения контекста авторизации из HTTP запроса через интерфейс HttpServerPrincipalExtractor.
Этот интерфейс позволяет реализовать любую схему аутентификации: Basic/ApiKey/Bearer/OAuth.
Принцип работы¶
HttpServerPrincipalExtractor<T> извлекает токен из запроса (обычно из заголовка Authorization) и возвращает объект Principal.
Полученный Principal сохраняется в Context запроса и может быть получен в любом месте обработки запроса через Principal.current().
public interface HttpServerPrincipalExtractor<T extends Principal> {
CompletionStage<T> extract(HttpServerRequest request, @Nullable String value);
}
Где:
request— текущий HTTP запрос, из которого можно извлечь дополнительные данные (заголовки, параметры)value— значение токена, извлеченное из заголовкаAuthorization(или другого источника)T extends Principal— тип контекста авторизации, который будет сохранен вContext
Пользовательский Principal¶
Пользователь может создать простой принципал для API при необходимости, с полями или без них:
Для передачи дополнительной информации об авторизации (userId, роли, scope) создайте собственную реализацию Principal:
Если требуется работа с scope (областями видимости), используйте интерфейс PrincipalWithScopes:
Базовый пример¶
Простой пример извлечения API ключа из заголовка Authorization:
@Module
public interface AuthModule {
@ConfigSource("auth.apiKey")
interface ApiKeyAuthConfig {
String value();
}
default HttpServerPrincipalExtractor<Principal> apiKeyExtractor(ApiKeyAuthConfig config) {
return (request, value) -> {
if (value == null || !config.value().equals(value)) {
return CompletableFuture.failedFuture(
new IllegalAccessException("Invalid API key")
);
}
return CompletableFuture.completedFuture(new ApiPrincipal("api-client"));
};
}
}
@Module
interface AuthModule {
@ConfigSource("auth.apiKey")
interface ApiKeyAuthConfig {
fun value(): String
}
fun apiKeyExtractor(config: ApiKeyAuthConfig): HttpServerPrincipalExtractor<Principal> {
return HttpServerPrincipalExtractor { request, value ->
if (value == null || config.value() != value) {
return@HttpServerPrincipalExtractor CompletableFuture.failedFuture(
IllegalAccessException("Invalid API key")
)
}
CompletableFuture.completedFuture(ApiPrincipal("api-client"))
}
}
}
Bearer токен¶
Пример извлечения Bearer токена с пользовательской реализацией Principal:
@Module
public interface BearerAuthModule {
default HttpServerPrincipalExtractor<UserContext> bearerExtractor(TokenValidator validator) {
return (request, value) -> {
if (value == null || !value.startsWith("Bearer ")) {
return CompletableFuture.failedFuture(
new IllegalAccessException("No Bearer token")
);
}
String token = value.substring(7);
return validator.validate(token)
.thenApply(userData -> new UserContext(userData.userId(), userData.roles()));
};
}
}
@Module
interface BearerAuthModule {
fun bearerExtractor(validator: TokenValidator): HttpServerPrincipalExtractor<UserContext> {
return HttpServerPrincipalExtractor { request, value ->
if (value == null || !value.startsWith("Bearer ")) {
return CompletableFuture.failedFuture(
IllegalAccessException("No Bearer token")
)
}
val token = value.substring(7)
validator.validate(token)
.thenApply { userData ->
UserContext(userData.userId, userData.roles)
}
}
}
}
Получение Principal¶
Получить текущий контекст авторизации можно в любом месте обработки запроса:
@Component
@HttpController
public class SecureController {
@HttpRoute(method = HttpMethod.GET, path = "/secure")
public String getSecureData() {
Principal principal = Principal.current();
if (principal instanceof UserContext user) {
return "Hello, user: " + user.userId();
}
throw new SecurityException("Not authenticated");
}
}
@Component
@HttpController
class SecureController {
@HttpRoute(method = HttpMethod.GET, path = "/secure")
fun getSecureData(): String {
val principal = Principal.current()
return if (principal is UserContext) {
"Hello, user: ${principal.userId}"
} else {
throw SecurityException("Not authenticated")
}
}
}
OAuth2¶
Для OAuth2 авторизации создайте HttpServerPrincipalExtractor, который проверяет токен через OAuth2 provider:
@Module
public interface OAuth2Module {
default HttpServerPrincipalExtractor<ScopedUser> oauth2Extractor(OAuth2Client oauth2Client) {
return (request, value) -> {
if (value == null || !value.startsWith("Bearer ")) {
return CompletableFuture.failedFuture(
new IllegalAccessException("No OAuth2 token")
);
}
String token = value.substring(7);
return oauth2Client.introspect(token)
.thenApply(introspection ->
new ScopedUser(
introspection.subject(),
introspection.scopes()
)
);
};
}
}
@Module
interface OAuth2Module {
fun oauth2Extractor(oauth2Client: OAuth2Client): HttpServerPrincipalExtractor<ScopedUser> {
return HttpServerPrincipalExtractor { request, value ->
if (value == null || !value.startsWith("Bearer ")) {
return CompletableFuture.failedFuture(
IllegalAccessException("No OAuth2 token")
)
}
val token = value.substring(7)
oauth2Client.introspect(token)
.thenApply { introspection ->
ScopedUser(introspection.subject, introspection.scopes)
}
}
}
}
Проверка scope¶
Для проверки scope можно создать перехватчик, который проверяет PrincipalWithScopes:
@Component
public final class ScopeCheckingInterceptor implements HttpServerInterceptor {
private final String requiredScope;
public ScopeCheckingInterceptor(@ConfigSource("auth.requiredScope") String requiredScope) {
this.requiredScope = requiredScope;
}
@Override
public CompletionStage<HttpServerResponse> intercept(Context context,
HttpServerRequest request,
InterceptChain chain) {
Principal principal = Principal.current(context);
if (principal instanceof PrincipalWithScopes scoped) {
if (!scoped.scopes().contains(requiredScope)) {
return CompletableFuture.failedFuture(
HttpServerResponseException.of(403, "Insufficient scope")
);
}
} else {
return CompletableFuture.failedFuture(
HttpServerResponseException.of(403, "No scopes available")
);
}
return chain.process(context, request);
}
}
@Component
class ScopeCheckingInterceptor(
@ConfigSource("auth.requiredScope") private val requiredScope: String
) : HttpServerInterceptor {
override fun intercept(
context: Context,
request: HttpServerRequest,
chain: HttpServerInterceptor.InterceptChain
): CompletionStage<HttpServerResponse> {
val principal = Principal.current(context)
if (principal is PrincipalWithScopes) {
if (!principal.scopes.contains(requiredScope)) {
return CompletableFuture.failedFuture(
HttpServerResponseException.of(403, "Insufficient scope")
)
}
} else {
return CompletableFuture.failedFuture(
HttpServerResponseException.of(403, "No scopes available")
)
}
return chain.process(context, request)
}
}
OpenAPI интеграция¶
При использовании Kora OpenAPI Generator авторизация настраивается автоматически на основе спецификации OpenAPI. Генератор создает:
- Интерфейс
ApiSecurityс классами-маркерами для каждого типа авторизации HttpServerInterceptorдля каждого security scheme- Требует предоставить
HttpServerPrincipalExtractorс соответствующим@Tag
Пример из kora-examples:
@KoraApp
public interface Application extends
HoconConfigModule,
UndertowHttpServerModule,
JsonModule {
@Tag(ApiSecurity.ApiKeyAuth.class)
default HttpServerPrincipalExtractor<Principal> apiKeyExtractor(DataApiAuthConfig config) {
return (request, value) -> {
if (value == null || !config.value().equals(value)) {
throw new SecurityException("Invalid API key");
}
return CompletableFuture.completedFuture(
new DataApiPrincipal("data-api-client")
);
};
}
}
где DataApiPrincipal:
@KoraApp
interface Application :
HoconConfigModule,
UndertowHttpServerModule,
JsonModule {
@Tag(ApiSecurity.ApiKeyAuth::class)
fun apiKeyExtractor(config: DataApiAuthConfig): HttpServerPrincipalExtractor<Principal> {
return HttpServerPrincipalExtractor { request, value ->
if (value == null || config.value() != value) {
throw SecurityException("Invalid API key")
}
CompletableFuture.completedFuture(
DataApiPrincipal("data-api-client")
)
}
}
}
где DataApiPrincipal:
Конфигурация:
Обработка ошибок¶
Если HttpServerPrincipalExtractor выбрасывает исключение или возвращает null, запрос отклоняется с кодом 403 Forbidden.
Для кастомной обработки ошибок авторизации используйте перехватчик:
@Tag(HttpServerModule.class)
@Component
public final class AuthErrorInterceptor implements HttpServerInterceptor {
@Override
public CompletionStage<HttpServerResponse> intercept(Context context,
HttpServerRequest request,
InterceptChain chain) {
return chain.process(context, request).exceptionally(e -> {
if (e instanceof CompletionException) {
e = e.getCause();
}
if (e instanceof IllegalAccessException) {
return HttpServerResponse.of(401, HttpBody.plaintext("Unauthorized: " + e.getMessage()));
}
if (e instanceof SecurityException) {
return HttpServerResponse.of(403, HttpBody.plaintext("Forbidden: " + e.getMessage()));
}
throw new CompletionException(e);
});
}
}
@Tag(HttpServerModule::class)
@Component
class AuthErrorInterceptor : HttpServerInterceptor {
override fun intercept(
context: Context,
request: HttpServerRequest,
chain: HttpServerInterceptor.InterceptChain
): CompletionStage<HttpServerResponse> {
return chain.process(context, request).exceptionally { e ->
val error = if (e is CompletionException) e.cause!! else e
when (error) {
is IllegalAccessException ->
HttpServerResponse.of(401, HttpBody.plaintext("Unauthorized: ${error.message}"))
is SecurityException ->
HttpServerResponse.of(403, HttpBody.plaintext("Forbidden: ${error.message}"))
else -> throw CompletionException(error)
}
}
}
}
Телеметрия¶
HTTP Server использует контракт телеметрии для логирования, метрик и трассировки запросов.
Конфигурация телеметрии (секция telemetry { logging / metrics / tracing }) описана в разделе Конфигурация.
Точки расширения находятся в ru.tinkoff.kora.http.server.common.telemetry.
Для каждого HTTP-запроса создаётся HttpServerTelemetry.HttpServerTelemetryContext, который закрывается по завершении обработки запроса.
Запрос описывается через параметры обработчика телеметрии, включая метод, путь, статус ответа и длительность.
Фабрика по умолчанию DefaultHttpServerTelemetryFactory объединяет три фабрики:
- HttpServerLoggerFactory строит HttpServerLogger для логирования начала/конца обработки запроса;
- HttpServerMetricsFactory строит HttpServerMetrics для записи метрик запросов;
- HttpServerTracerFactory строит HttpServerTracer для распределённой трассировки.
Метрики и трассировка описаны в разделе Справочник метрик.