Руководство по продвинутому HTTP-клиенту¶
В этом руководстве рассматриваются продвинутые шаблоны декларативного HTTP-клиента в Kora. Вы узнаете, как клиенты вызывают маршруты с формами, multipart и вспомогательным транспортом, как пользовательские преобразователи тела задают форму нестандартных полезных нагрузок запроса и ответа, и как типизированные варианты ответа представляют разные HTTP-статусы. Вы также увидите, как перехватчики уровня метода и уровня клиента добавляют сквозное поведение, например авторизацию по ключу API.
Если в процессе захочется сверить результат, используйте готовое рабочее приложение: Kora Java HTTP Client Advanced App.
Если в процессе захочется сверить результат, используйте готовое рабочее приложение: Kora Kotlin HTTP Client Advanced App.
Что вы создадите¶
Вы расширите клиентское приложение и добавите:
- отдельный
DataApiClient - запросы
FormUrlEncodedиFormMultipart - пользовательский
HttpClientRequestMapper - декодирование с учетом кода ответа через
@ResponseCodeMapper HttpClientInterceptorуровня метода- общий для клиента перехватчик авторизации по ключу API
- отдельную агрегирующую конечную точку в
ClientTestController, которая проверяет продвинутые маршруты данных
Что вам понадобится¶
- JDK 17 или новее
- Gradle 7+
- Docker Desktop или другая локальная Docker-среда для тестов на основе контейнеров
- текстовый редактор или среда разработки
Требования¶
Обязательно: пройдите руководства по HTTP-клиенту и продвинутому HTTP-серверу
Это руководство предполагает, что вы уже прошли Руководство по продвинутому HTTP-серверу и Руководство по HTTP-клиенту, а продвинутая серверная сторона уже предоставляет маршруты DataController.
Если вы еще не прошли эти руководства, сначала сделайте это, потому что там уже разобран базовый поток HTTP-сервера/клиента, а это руководство сосредоточено только на продвинутом клиентском сопоставлении для маршрутов продвинутого сервера.
Обзор¶
Продвинутые HTTP-клиенты появляются тогда, когда удаленный API — это не просто JSON CRUD. Некоторые сервисы предоставляют form-конечные точки, multipart-загрузки, пользовательские форматы полезной нагрузки или контракты ответов, где разные коды состояния означают разные типизированные исходы. Хороший клиент должен явно моделировать эти детали и не протаскивать низкоуровневый HTTP-код в остальные части приложения.
Ключевое проектное решение — держать продвинутую транспортную механику рядом со сгенерированным клиентом. Кодирование форм, построение multipart, пользовательское сопоставление, декодирование статусов и заголовки авторизации — это задачи границы клиента, а не задачи бизнес-логики.
HTTP-формы¶
Декларативные клиенты Kora могут описывать несколько стилей HTTP-взаимодействия:
- параметры формы для запросов
application/x-www-form-urlencoded - multipart-части для вызовов в стиле загрузки файлов
- пользовательские преобразователи запросов для полезных нагрузок, которые не подходят под стандартную JSON-модель
- типизированное сопоставление ответов для API, где коды состояния несут доменный смысл
Главный принцип тот же, что в базовом руководстве по клиенту: сигнатура метода должна описывать удаленный контракт достаточно ясно, чтобы вызывающему коду не приходилось вручную строить запросы.
Перехватчики клиента¶
Клиентские перехватчики выполняются вокруг исходящих вызовов. Они полезны для сквозного транспортного поведения, например журналирования, идентификаторов корреляции, заголовков авторизации, ключей API или метрик. Поскольку перехватчики живут на границе клиента, они помогают не дублировать один и тот же код заголовков или журналирования в каждом методе.
В этом руководстве перехватчики используются и для поведения уровня метода, и для переиспользуемой авторизации уровня клиента.
Точечные изменения¶
Продвинутые возможности клиента легко расползаются по приложению, если сгенерированный клиент используется напрямую везде. В этом руководстве вокруг клиента сохраняется сервисная обертка, чтобы form-вызовы, multipart-вызовы, пользовательское декодирование и авторизация оставались рядом с транспортной границей. Остальная часть приложения может работать с более понятными методами и типизированными результатами.
Практический поток:
- добавить отдельный клиент для продвинутых маршрутов данных
- декларативно вызвать form- и multipart-конечные точки
- добавить пользовательский преобразователь запроса для одной формы полезной нагрузки
- декодировать статусы ответа в типизированные результаты
- подключить журналирование и авторизацию по ключу API через перехватчики
Новый HTTP-клиент¶
Первая продвинутая концепция клиента все еще очень конкретна: вызвать дополнительные маршруты, введенные в DataController.
Мы держим эти вызовы в отдельном DataApiClient, чтобы транспортно-насыщенные примеры не загромождали более простой UserApiClient.
package ru.tinkoff.kora.guide.httpclient.client;
import java.nio.charset.StandardCharsets;
import java.util.List;
import ru.tinkoff.kora.http.client.common.annotation.HttpClient;
import ru.tinkoff.kora.http.common.HttpMethod;
import ru.tinkoff.kora.http.common.annotation.HttpRoute;
import ru.tinkoff.kora.http.common.form.FormMultipart;
import ru.tinkoff.kora.http.common.form.FormUrlEncoded;
import ru.tinkoff.kora.json.common.annotation.Json;
@HttpClient(configPath = "httpClient.dataApi")
public interface DataApiClient {
@HttpRoute(method = HttpMethod.POST, path = "/data/form")
String processForm(FormUrlEncoded body);
@HttpRoute(method = HttpMethod.POST, path = "/data/upload")
@Json
UploadResponse processUpload(FormMultipart body);
default UploadResponse sampleUpload() {
return this.processUpload(new FormMultipart(List.of(
FormMultipart.data("field1", "some data content"),
FormMultipart.file("field2", "example1.txt", "text/plain", "some file content".getBytes(StandardCharsets.UTF_8)))));
}
@Json
record UploadResponse(int fileCount, List<String> fileNames) {}
}
package ru.tinkoff.kora.guide.httpclient.client
import java.nio.charset.StandardCharsets
import ru.tinkoff.kora.http.client.common.annotation.HttpClient
import ru.tinkoff.kora.http.common.HttpMethod
import ru.tinkoff.kora.http.common.annotation.HttpRoute
import ru.tinkoff.kora.http.common.form.FormMultipart
import ru.tinkoff.kora.http.common.form.FormUrlEncoded
import ru.tinkoff.kora.json.common.annotation.Json
@HttpClient(configPath = "httpClient.dataApi")
interface DataApiClient {
@HttpRoute(method = HttpMethod.POST, path = "/data/form")
fun processForm(body: FormUrlEncoded): String
@HttpRoute(method = HttpMethod.POST, path = "/data/upload")
@Json
fun processUpload(body: FormMultipart): UploadResponse
fun sampleUpload(): UploadResponse {
return processUpload(
FormMultipart(
listOf(
FormMultipart.data("field1", "some data content"),
FormMultipart.file("field2", "example1.txt", "text/plain", "some file content".toByteArray(StandardCharsets.UTF_8))
)
)
)
}
@Json
data class UploadResponse(val fileCount: Int, val fileNames: List<String>)
}
Такое разделение помогает:
UserApiClientостается сосредоточен на CRUDDataApiClientстановится местом для продвинутых транспортных примеров- базовое руководство остается простым для чтения
Преобразователь параметра¶
Подробнее о преобразователях тела запроса клиента смотрите в разделе тела запроса HTTP-клиента.
Иногда тело запроса не должно использовать обычный поток JSON- или form-сопоставления. Удаленная конечная точка может ожидать очень конкретное текстовое или бинарное представление, а вы все равно хотите моделировать вход своим типом.
Именно для этого нужен HttpClientRequestMapper<T>.
В этом руководстве используется небольшой пример:
- метод принимает
PlainTextGreetingBody - преобразователь превращает его в обычное текстовое HTTP-тело
- продвинутый сервер возвращает этот сопоставленный текст обратно
Добавьте эти части внутрь DataApiClient.java:
import ru.tinkoff.kora.common.Context;
import ru.tinkoff.kora.common.Mapping;
import ru.tinkoff.kora.http.client.common.request.HttpClientRequestMapper;
import ru.tinkoff.kora.http.common.body.HttpBody;
import ru.tinkoff.kora.http.common.body.HttpBodyOutput;
record PlainTextGreetingBody(String name) {}
final class GreetingRequestMapper implements HttpClientRequestMapper<PlainTextGreetingBody> {
@Override
public HttpBodyOutput apply(Context ctx, PlainTextGreetingBody value) {
return HttpBody.plaintext("Hello " + value.name());
}
}
@HttpRoute(method = HttpMethod.POST, path = "/data/mapping-request")
String processMappedRequest(@Mapping(GreetingRequestMapper.class) PlainTextGreetingBody body);
Добавьте ту же идею в DataApiClient.kt:
import ru.tinkoff.kora.common.Context
import ru.tinkoff.kora.common.Mapping
import ru.tinkoff.kora.http.client.common.request.HttpClientRequestMapper
import ru.tinkoff.kora.http.common.body.HttpBody
import ru.tinkoff.kora.http.common.body.HttpBodyOutput
data class PlainTextGreetingBody(val name: String)
class GreetingRequestMapper : HttpClientRequestMapper<PlainTextGreetingBody> {
override fun apply(ctx: Context, value: PlainTextGreetingBody): HttpBodyOutput {
return HttpBody.plaintext("Hello ${value.name}")
}
}
@HttpRoute(method = HttpMethod.POST, path = "/data/mapping-request")
fun processMappedRequest(@Mapping(GreetingRequestMapper::class) body: PlainTextGreetingBody): String
Это клиентский аналог преобразователей запросов, которые мы вводили в руководстве по продвинутому серверу: типизированный объект превращается в транспортное представление в одном ясном месте.
Сопоставление по коду ответа¶
Стандартное поведение клиента часто рассматривает ответ как:
- успешное тело
- или исключение
Этого достаточно для многих API. Но иногда контракт намеренно говорит:
200возвращает одну JSON-форму- ответы не с
200возвращают другую JSON-форму
Именно здесь полезен @ResponseCodeMapper.
В этом руководстве GET /data/mapping-by-code/{code} ведет себя так:
200возвращает{"message":"Hello from response mapper"}- другие коды возвращают
{"message":"Request failed with code <status>"}через общий серверныйErrorResponse
Мы моделируем это как один sealed-тип результата.
Добавьте это внутрь DataApiClient.java:
import java.io.IOException;
import ru.tinkoff.kora.guide.httpclient.client.DataApiClient.MappedResponse.Error;
import ru.tinkoff.kora.guide.httpclient.client.DataApiClient.MappedResponse.Payload;
import ru.tinkoff.kora.http.client.common.HttpClientDecoderException;
import ru.tinkoff.kora.http.client.common.annotation.ResponseCodeMapper;
import ru.tinkoff.kora.http.client.common.response.HttpClientResponse;
import ru.tinkoff.kora.http.client.common.response.HttpClientResponseMapper;
import ru.tinkoff.kora.http.common.annotation.Path;
import ru.tinkoff.kora.json.common.JsonReader;
sealed interface MappedResponse permits Payload, Error {
@Json
record Payload(String message) implements MappedResponse {}
@Json
record Error(int code, String message) implements MappedResponse {}
@Json
record ErrorPayload(String message) {}
}
final class MappedResponseSuccessMapper implements HttpClientResponseMapper<MappedResponse> {
private final JsonReader<Payload> jsonReader;
public MappedResponseSuccessMapper(JsonReader<Payload> jsonReader) {
this.jsonReader = jsonReader;
}
@Override
public MappedResponse apply(HttpClientResponse response) throws IOException, HttpClientDecoderException {
try (var is = response.body().asInputStream()) {
return this.jsonReader.read(is.readAllBytes());
}
}
}
final class MappedResponseErrorMapper implements HttpClientResponseMapper<MappedResponse> {
private final JsonReader<MappedResponse.ErrorPayload> jsonReader;
public MappedResponseErrorMapper(JsonReader<MappedResponse.ErrorPayload> jsonReader) {
this.jsonReader = jsonReader;
}
@Override
public MappedResponse apply(HttpClientResponse response) throws IOException, HttpClientDecoderException {
try (var is = response.body().asInputStream()) {
var payload = this.jsonReader.read(is.readAllBytes());
return new Error(response.code(), payload.message());
}
}
}
@ResponseCodeMapper(code = ResponseCodeMapper.DEFAULT, mapper = MappedResponseErrorMapper.class)
@ResponseCodeMapper(code = 200, mapper = MappedResponseSuccessMapper.class)
@HttpRoute(method = HttpMethod.GET, path = "/data/mapping-by-code/{code}")
MappedResponse getMappedByCode(@Path int code);
Добавьте ту же идею в Kotlin:
import java.io.IOException
import ru.tinkoff.kora.http.client.common.HttpClientDecoderException
import ru.tinkoff.kora.http.client.common.annotation.ResponseCodeMapper
import ru.tinkoff.kora.http.client.common.response.HttpClientResponse
import ru.tinkoff.kora.http.client.common.response.HttpClientResponseMapper
import ru.tinkoff.kora.http.common.annotation.Path
import ru.tinkoff.kora.json.common.JsonReader
sealed interface MappedResponse {
@Json
data class Payload(val message: String) : MappedResponse
@Json
data class Error(val code: Int, val message: String) : MappedResponse
@Json
data class ErrorPayload(val message: String)
}
class MappedResponseSuccessMapper(
private val jsonReader: JsonReader<MappedResponse.Payload>
) : HttpClientResponseMapper<MappedResponse> {
override fun apply(response: HttpClientResponse): MappedResponse {
response.body().asInputStream().use { input ->
return jsonReader.read(input.readAllBytes())
}
}
}
class MappedResponseErrorMapper(
private val jsonReader: JsonReader<MappedResponse.ErrorPayload>
) : HttpClientResponseMapper<MappedResponse> {
override fun apply(response: HttpClientResponse): MappedResponse {
response.body().asInputStream().use { input ->
val payload = jsonReader.read(input.readAllBytes())
return MappedResponse.Error(response.code(), payload.message)
}
}
}
@ResponseCodeMapper(code = ResponseCodeMapper.DEFAULT, mapper = MappedResponseErrorMapper::class)
@ResponseCodeMapper(code = 200, mapper = MappedResponseSuccessMapper::class)
@HttpRoute(method = HttpMethod.GET, path = "/data/mapping-by-code/{code}")
fun getMappedByCode(@Path code: Int): MappedResponse
Этот шаблон ценен тем, что транспортная логика, зависящая от кода состояния, остается рядом с методом клиента, а не просачивается в каждый вызывающий код.
Обратите внимание на маленькую, но важную деталь этой версии примера:
- JSON-тело ошибки содержит только
message - преобразователь берет
codeиз фактической строки HTTP-статуса
Так формат ошибки на стороне сервера остается проще, но клиент все равно может предоставить более богатый типизированный результат.
Перехватчик клиента¶
Подробнее о перехватчиках клиента, их области действия и порядке выполнения смотрите в разделе перехватчиков HTTP-клиента.
Следующая продвинутая концепция — перехватчик уровня метода.
Перехватчики полезны, когда нужно переиспользуемое поведение вокруг вызова, например:
- журналирование
- метрики
- пользовательская транспортная диагностика
Мы намеренно держим этот пример небольшим и применяем его только к getMappedByCode().
Добавьте это внутрь DataApiClient.java:
import java.util.concurrent.CompletionStage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ru.tinkoff.kora.http.client.common.interceptor.HttpClientInterceptor;
import ru.tinkoff.kora.http.client.common.request.HttpClientRequest;
import ru.tinkoff.kora.http.client.common.response.HttpClientResponse;
import ru.tinkoff.kora.http.common.annotation.InterceptWith;
final class MethodLoggingInterceptor implements HttpClientInterceptor {
private static final Logger logger = LoggerFactory.getLogger(MethodLoggingInterceptor.class);
@Override
public CompletionStage<HttpClientResponse> processRequest(Context ctx, InterceptChain chain, HttpClientRequest request)
throws Exception {
logger.info("Advanced HTTP client interceptor invoked");
return chain.process(ctx, request);
}
}
@InterceptWith(MethodLoggingInterceptor.class)
@ResponseCodeMapper(code = ResponseCodeMapper.DEFAULT, mapper = MappedResponseErrorMapper.class)
@ResponseCodeMapper(code = 200, mapper = MappedResponseSuccessMapper.class)
@HttpRoute(method = HttpMethod.GET, path = "/data/mapping-by-code/{code}")
MappedResponse getMappedByCode(@Path int code);
Добавьте ту же идею в Kotlin:
import java.util.concurrent.CompletionStage
import org.slf4j.LoggerFactory
import ru.tinkoff.kora.http.client.common.interceptor.HttpClientInterceptor
import ru.tinkoff.kora.http.client.common.request.HttpClientRequest
import ru.tinkoff.kora.http.client.common.response.HttpClientResponse
import ru.tinkoff.kora.http.common.annotation.InterceptWith
class MethodLoggingInterceptor : HttpClientInterceptor {
companion object {
private val logger = LoggerFactory.getLogger(MethodLoggingInterceptor::class.java)
}
override fun processRequest(
ctx: Context,
chain: HttpClientInterceptor.InterceptChain,
request: HttpClientRequest
): CompletionStage<HttpClientResponse> {
logger.info("Advanced HTTP client interceptor invoked")
return chain.process(ctx, request)
}
}
@InterceptWith(MethodLoggingInterceptor::class)
@ResponseCodeMapper(code = ResponseCodeMapper.DEFAULT, mapper = MappedResponseErrorMapper::class)
@ResponseCodeMapper(code = 200, mapper = MappedResponseSuccessMapper::class)
@HttpRoute(method = HttpMethod.GET, path = "/data/mapping-by-code/{code}")
fun getMappedByCode(@Path code: Int): MappedResponse
Это хороший шаблон «локальное перед глобальным»: мы добавляем поведение только там, где оно действительно нужно примеру.
Авторизация по ключу¶
Для более широкого описания авторизации на стороне HTTP-клиента смотрите раздел авторизации.
Руководство по продвинутому серверу защитило DataController простой проверкой ключа API в заголовке Authorization.
На этом этапе мы уже понимаем сами продвинутые маршруты, поэтому теперь имеет смысл добавить еще одну переиспользуемую клиентскую задачу: автоматическую авторизацию.
Мы не хотим, чтобы каждый вызывающий код вручную помнил об этом заголовке. Это как раз тот повторяющийся транспортный навык, которому место в перехватчике.
Создайте контракт конфигурации:
Создайте перехватчик авторизации:
package ru.tinkoff.kora.guide.httpclient.client;
import java.util.concurrent.CompletionStage;
import ru.tinkoff.kora.common.Component;
import ru.tinkoff.kora.common.Context;
import ru.tinkoff.kora.http.client.common.interceptor.HttpClientInterceptor;
import ru.tinkoff.kora.http.client.common.request.HttpClientRequest;
import ru.tinkoff.kora.http.client.common.response.HttpClientResponse;
@Component
public final class ApiKeyAuthInterceptor implements HttpClientInterceptor {
private final ApiKeyAuthConfig config;
public ApiKeyAuthInterceptor(ApiKeyAuthConfig config) {
this.config = config;
}
@Override
public CompletionStage<HttpClientResponse> processRequest(Context ctx, InterceptChain chain, HttpClientRequest request)
throws Exception {
var authorizedRequest = request.toBuilder()
.header("Authorization", this.config.value())
.build();
return chain.process(ctx, authorizedRequest);
}
}
package ru.tinkoff.kora.guide.httpclient.client
import java.util.concurrent.CompletionStage
import ru.tinkoff.kora.common.Component
import ru.tinkoff.kora.common.Context
import ru.tinkoff.kora.http.client.common.interceptor.HttpClientInterceptor
import ru.tinkoff.kora.http.client.common.request.HttpClientRequest
import ru.tinkoff.kora.http.client.common.response.HttpClientResponse
@Component
class ApiKeyAuthInterceptor(
private val config: ApiKeyAuthConfig
) : HttpClientInterceptor {
override fun processRequest(
ctx: Context,
chain: HttpClientInterceptor.InterceptChain,
request: HttpClientRequest
): CompletionStage<HttpClientResponse> {
val authorizedRequest = request.toBuilder()
.header("Authorization", config.value())
.build()
return chain.process(ctx, authorizedRequest)
}
}
Примените его к DataApiClient:
Это очень распространенный сценарий использования перехватчика. Команды часто применяют тот же шаблон для:
- заголовков
Authorization - файлы cookie
- ключей API
- других метаданных запроса, которые всегда должны добавляться автоматически
Настройте ключ API:
Полное описание настроек смотрите в разделе Конфигурация.
auth {
apiKey {
value = "MySecuredApiKey" //(1)!
value = ${?HTTP_ADVANCED_API_KEY} //(2)!
}
}
- Настроенное значение, которое использует компонент руководства.
- Настроенное значение, которое использует компонент руководства. Необязательное переопределение через
HTTP_ADVANCED_API_KEY.
Оба приложения могут использовать одно и то же локальное значение по умолчанию, а HTTP_ADVANCED_API_KEY сохраняет пример удобным для разных окружений.
Императивный клиент¶
Декларативные интерфейсы @HttpClient — обычный стиль уровня приложения, но Kora также предоставляет базовый компонент HttpClient. Это полезно, когда нужно динамически построить запрос, применить
перехватчик вручную или отладить то, что декларативный клиент от вас скрывает.
Сначала добавьте небольшой контракт конфигурации для того же удаленного базового URL, который использует DataApiClient:
Теперь добавьте небольшой ручной клиент. Обратите внимание: он не кладет заголовок авторизации прямо в запрос. Он переиспользует тот же перехватчик авторизации через this.httpClient.with(...).
package ru.tinkoff.kora.guide.httpclient.client;
import java.nio.charset.StandardCharsets;
import ru.tinkoff.kora.common.Component;
import ru.tinkoff.kora.http.client.common.HttpClient;
import ru.tinkoff.kora.http.client.common.request.HttpClientRequest;
@Component
public final class ManualDataHttpClient {
private final HttpClient httpClient;
private final dataApiConfig dataApiConfig;
private final ApiKeyAuthInterceptor apiKeyAuthInterceptor;
public ManualDataHttpClient(
HttpClient httpClient,
dataApiConfig dataApiConfig,
ApiKeyAuthInterceptor apiKeyAuthInterceptor) {
this.httpClient = httpClient;
this.dataApiConfig = dataApiConfig;
this.apiKeyAuthInterceptor = apiKeyAuthInterceptor;
}
public String pingManualHandler() {
var request = HttpClientRequest.of("GET", this.dataApiConfig.url() + "/manual/data/ping")
.build();
var response = this.httpClient.with(this.apiKeyAuthInterceptor)
.execute(request)
.toCompletableFuture()
.join();
if (response.code() != 200) {
throw new IllegalStateException("Manual HTTP call failed with status " + response.code());
}
try (var body = response.body().asInputStream()) {
return new String(body.readAllBytes(), StandardCharsets.UTF_8);
} catch (Exception exception) {
throw new IllegalStateException("Failed to read manual HTTP response body", exception);
}
}
}
package ru.tinkoff.kora.guide.httpclient.client
import java.nio.charset.StandardCharsets
import ru.tinkoff.kora.common.Component
import ru.tinkoff.kora.http.client.common.HttpClient
import ru.tinkoff.kora.http.client.common.request.HttpClientRequest
@Component
class ManualDataHttpClient(
private val httpClient: HttpClient,
private val dataApiConfig: dataApiConfig,
private val apiKeyAuthInterceptor: ApiKeyAuthInterceptor
) {
fun pingManualHandler(): String {
val request = HttpClientRequest.of("GET", dataApiConfig.url() + "/manual/data/ping")
.build()
val response = httpClient.with(apiKeyAuthInterceptor)
.execute(request)
.toCompletableFuture()
.join()
if (response.code() != 200) {
throw IllegalStateException("Manual HTTP call failed with status ${response.code()}")
}
response.body().asInputStream().use { body ->
return String(body.readAllBytes(), StandardCharsets.UTF_8)
}
}
}
Этот пример намеренно небольшой, но он показывает три важные детали:
HttpClientRequest.of(...)явно строит исходящий запросHttpClient.with(...)возвращает клиент, украшенный перехватчикомexecute(...)— это низкоуровневая операция за более высокоуровневыми декларативными клиентами
После компиляции сгенерированный граф приложения показывает, что Kora связывает базовый клиент, конфигурацию и перехватчик с ручным клиентом:
Этот сгенерированный граф — полезный источник истины, когда нужно подтвердить, какая реализация HttpClient и какие перехватчики действительно внедряются.
Контроллер проверки¶
Теперь мы связываем продвинутые возможности клиента в один агрегирующий сценарий, посвященный только маршрутам DataController.
В базовом руководстве уже есть ориентированная на пользователей агрегирующая конечная точка. Мы сохраняем это разделение:
testAllUserEndpoints()относится к базовому руководству по клиентуtestAllDataEndpoints()относится к этому продвинутому руководству
package ru.tinkoff.kora.guide.httpclient.controller;
import ru.tinkoff.kora.common.Component;
import ru.tinkoff.kora.guide.httpclient.client.DataApiClient;
import ru.tinkoff.kora.guide.httpclient.client.ManualDataHttpClient;
import ru.tinkoff.kora.http.common.HttpMethod;
import ru.tinkoff.kora.http.common.annotation.HttpRoute;
import ru.tinkoff.kora.http.common.form.FormUrlEncoded;
import ru.tinkoff.kora.http.server.common.annotation.HttpController;
import ru.tinkoff.kora.json.common.annotation.Json;
@Component
@HttpController
public final class ClientTestController {
private final DataApiClient dataApiClient;
private final ManualDataHttpClient manualDataHttpClient;
public ClientTestController(DataApiClient dataApiClient, ManualDataHttpClient manualDataHttpClient) {
this.dataApiClient = dataApiClient;
this.manualDataHttpClient = manualDataHttpClient;
}
@HttpRoute(method = HttpMethod.POST, path = "/client/test-all-data-endpoints")
@Json
public TestResults testAllDataEndpoints() {
try {
var formResult = this.dataApiClient.processForm(form("name", "John"));
boolean formProcessed = "Hello World, John".equals(formResult);
var uploadResult = this.dataApiClient.sampleUpload();
boolean uploadProcessed = uploadResult.fileCount() == 2;
var mappedRequestResult = this.dataApiClient.processMappedRequest(new DataApiClient.PlainTextGreetingBody("Client Mapper"));
boolean customRequestMapped = "Received mapped body: Hello Client Mapper".equals(mappedRequestResult);
var mappedSuccess = this.dataApiClient.getMappedByCode(200);
var mappedFailure = this.dataApiClient.getMappedByCode(404);
boolean responseMapped = mappedSuccess instanceof DataApiClient.MappedResponse.Payload payload
&& "Hello from response mapper".equals(payload.message())
&& mappedFailure instanceof DataApiClient.MappedResponse.Error error
&& error.code() == 404
&& "Request failed with code 404".equals(error.message());
var manualPingResult = this.manualDataHttpClient.pingManualHandler();
boolean manualHttpClientCallProcessed = "manual-data-pong".equals(manualPingResult);
boolean allTestsPassed = formProcessed
&& uploadProcessed
&& customRequestMapped
&& responseMapped
&& manualHttpClientCallProcessed;
return new TestResults(
formProcessed,
uploadProcessed,
customRequestMapped,
responseMapped,
manualHttpClientCallProcessed,
allTestsPassed,
null);
} catch (Exception exception) {
return new TestResults(false, false, false, false, false, false, exception.getMessage());
}
}
private static FormUrlEncoded form(String... keyValues) {
FormUrlEncoded.FormPart[] parts = new FormUrlEncoded.FormPart[keyValues.length / 2];
for (int i = 0; i < keyValues.length; i += 2) {
parts[i / 2] = new FormUrlEncoded.FormPart(keyValues[i], keyValues[i + 1]);
}
return new FormUrlEncoded(parts);
}
@Json
public record TestResults(
boolean formProcessed,
boolean uploadProcessed,
boolean customRequestMapped,
boolean responseMapped,
boolean manualHttpClientCallProcessed,
boolean allTestsPassed,
String error) {}
}
package ru.tinkoff.kora.guide.httpclient.controller
import ru.tinkoff.kora.common.Component
import ru.tinkoff.kora.guide.httpclient.client.DataApiClient
import ru.tinkoff.kora.http.common.HttpMethod
import ru.tinkoff.kora.http.common.annotation.HttpRoute
import ru.tinkoff.kora.http.common.form.FormUrlEncoded
import ru.tinkoff.kora.http.server.common.annotation.HttpController
import ru.tinkoff.kora.json.common.annotation.Json
@Component
@HttpController
class ClientTestController(
private val dataApiClient: DataApiClient
) {
@HttpRoute(method = HttpMethod.POST, path = "/client/test-all-data-endpoints")
@Json
fun testAllDataEndpoints(): TestResults {
return try {
val formResult = dataApiClient.processForm(form("name", "John"))
val formProcessed = formResult == "Hello World, John"
val uploadResult = dataApiClient.sampleUpload()
val uploadProcessed = uploadResult.fileCount == 2
val mappedRequestResult = dataApiClient.processMappedRequest(DataApiClient.PlainTextGreetingBody("Client Mapper"))
val customRequestMapped = mappedRequestResult == "Received mapped body: Hello Client Mapper"
val mappedSuccess = dataApiClient.getMappedByCode(200)
val mappedFailure = dataApiClient.getMappedByCode(404)
val responseMapped =
mappedSuccess is DataApiClient.MappedResponse.Payload &&
mappedSuccess.message == "Hello from response mapper" &&
mappedFailure is DataApiClient.MappedResponse.Error &&
mappedFailure.code == 404 &&
mappedFailure.message == "Request failed with code 404"
val allTestsPassed = formProcessed && uploadProcessed && customRequestMapped && responseMapped
TestResults(
formProcessed,
uploadProcessed,
customRequestMapped,
responseMapped,
allTestsPassed,
null
)
} catch (e: Exception) {
TestResults(false, false, false, false, false, e.message)
}
}
private fun form(vararg keyValues: String): FormUrlEncoded {
val parts = Array(keyValues.size / 2) { index ->
FormUrlEncoded.FormPart(keyValues[index * 2], keyValues[index * 2 + 1])
}
return FormUrlEncoded(parts)
}
@Json
data class TestResults(
val formProcessed: Boolean,
val uploadProcessed: Boolean,
val customRequestMapped: Boolean,
val responseMapped: Boolean,
val allTestsPassed: Boolean,
val error: String?
)
}
Проверка приложения¶
Запустите продвинутый сервер и продвинутый клиент в разных терминалах.
Терминал 1: сервер¶
Приложение продвинутого сервера должно слушать http://localhost:8080.
Терминал 2: клиент¶
Приложение продвинутого клиента должно слушать http://localhost:8081.
Сценарий клиента¶
Ожидаемый результат: JSON-объект, где allTestsPassed равно true.
Лучшие практики¶
- Оставляйте базовое руководство по HTTP-клиенту сосредоточенным на самом простом JSON-first пути, а транспортно-насыщенные темы переносите в продвинутое продолжение.
- Используйте отдельные интерфейсы клиентов для разных областей удаленного API, когда это улучшает читаемость.
- Обращайтесь к
HttpClientRequestMapperтолько тогда, когда встроенных стилей сопоставления недостаточно. - Используйте
@ResponseCodeMapper, когда декодирование с учетом кода состояния является частью контракта. - Используйте перехватчики для повторяющегося транспортного поведения вроде журналирования или авторизации, а не повторяйте заголовки и шаблонный код вручную.
Итоги¶
Вы расширили базовое приложение HTTP-клиента:
- отдельным
DataApiClient - поддержкой form- и multipart-запросов
- пользовательским преобразователем запроса
- декодированием с учетом кода ответа
- перехватчиком уровня метода
- переиспользуемой авторизацией по ключу API
Результат отражает дух http-server-advanced.md: по одной продвинутой транспортной концепции за раз, и каждая вводится только после того, как более простой путь уже понятен.
Ключевые понятия¶
FormUrlEncodedиFormMultipartявляются полноценными клиентскими типами тела в KoraHttpClientRequestMapper<T>позволяет контролировать, как тип превращается в тело HTTP-запроса@ResponseCodeMapperпозволяет разным кодам состояния декодироваться в разные варианты одного типа результатаHttpClientInterceptorхорошо подходит и для локального журналирования, и для общего поведения авторизации
Устранение неполадок¶
Защищенные вызовы возвращают 403:
- Проверьте, что сервер и клиент используют одно и то же значение ключа API.
- Проверьте переменную окружения
HTTP_ADVANCED_API_KEYв обоих приложениях. - Помните, что переменная окружения переопределяет локальное значение по умолчанию из
application.conf.
Form- или multipart-запросы не работают:
- Убедитесь, что запущено приложение продвинутого сервера, а не только базовое серверное приложение.
- Проверьте, что
DataControllerоткрыт на целевом сервере.
Пользовательский преобразователь запроса не запускается:
- Убедитесь, что параметр использует
@Mapping(...). - Убедитесь, что преобразователь реализует
HttpClientRequestMapper<T>.
Сопоставление по коду ответа работает не так, как ожидалось:
- Внимательно проверьте записи
@ResponseCodeMapper. - Помните, что
ResponseCodeMapper.DEFAULTявляется запасным вариантом для всех неуказанных кодов. - Убедитесь, что серверный маршрут возвращает JSON-форму, которую ваш преобразователь ожидает для каждой ветки.
Журналирование перехватчика не появляется:
- Проверьте
@InterceptWith(...)на конкретном методе клиента. - Убедитесь, что класс перехватчика реализует
HttpClientInterceptor.
Что дальше?¶
- HTTP-сервер OpenAPI, если вы еще не прошли путь сервера от контракта.
- HTTP-клиент OpenAPI после HTTP-сервера OpenAPI, чтобы увидеть, как генерация по контракту моделирует похожее транспортное поведение.
- Шаблоны отказоустойчивости, чтобы защищать продвинутые исходящие вызовы повтором, временем ожидания, автоматическим выключателем и резервным ответом.
- Наблюдаемость, чтобы трассировать перехватчики, ручные вызовы
HttpClientи сопоставленные ответы.
Помощь¶
Если вы застряли:
- сравните с Kora Java HTTP Client Advanced App и Kora Kotlin HTTP Client Advanced App
- вернитесь к HTTP-клиенту, чтобы вспомнить базовую форму декларативного клиента
- вернитесь к Продвинутому HTTP-серверу, чтобы посмотреть серверные конечные точки, которые вызывает этот клиент
- проверьте документацию HTTP-клиента