Advanced HTTP Client Guide¶
This guide introduces advanced declarative HTTP client patterns in Kora. It covers how clients call form, multipart, and helper transport routes, how custom body mappers shape unusual request and response payloads, and how typed response variants represent different HTTP statuses. You will also see how method-level and client-level interceptors add cross-cutting behavior such as API-key authorization.
If you want to check your progress along the way, use the finished working example: Kora Java HTTP Client Advanced App.
If you want to check your progress along the way, use the finished working example: Kora Kotlin HTTP Client Advanced App.
What You'll Build¶
You will extend the client application with:
- a dedicated
DataApiClient FormUrlEncodedandFormMultipartrequests- a custom
HttpClientRequestMapper - response-code-aware decoding with
@ResponseCodeMapper - a method-level
HttpClientInterceptor - a client-wide API-key auth interceptor
- a dedicated aggregate endpoint in
ClientTestControllerthat exercises the advanced data routes
What You'll Need¶
- JDK 17 or later
- Gradle 7+
- Docker Desktop or another local Docker environment for container-based tests
- A text editor or IDE
Prerequisites¶
Required: Complete HTTP Client and Advanced HTTP Server Guides
This guide assumes you have completed HTTP Server Advanced Guide and HTTP Client Guide, and that the advanced server side already exposes the DataController routes.
If you haven't completed those guides yet, do that first, because they already cover the base HTTP server/client flow and this guide focuses only on advanced client mapping against the advanced server routes.
Overview¶
Advanced HTTP clients appear when a remote API is not just JSON CRUD. Some services expose form endpoints, multipart uploads, custom payload formats, or response contracts where different status codes mean different typed outcomes. A good client should model those details explicitly without leaking low-level HTTP code into the rest of the application.
The key design choice is to keep advanced transport mechanics near the generated client. Form encoding, multipart construction, custom mapping, status decoding, and authorization headers are all client-boundary concerns, not business logic concerns.
HTTP Forms¶
Kora declarative clients can describe several HTTP interaction styles:
- form parameters for
application/x-www-form-urlencodedrequests - multipart parts for upload-style calls
- custom request mappers for payloads that do not fit the default JSON model
- typed response mapping for APIs where status codes carry domain meaning
The main principle is the same as the basic client guide: the method signature should describe the remote contract clearly enough that callers do not need to build requests by hand.
Client Interceptors¶
Client interceptors run around outbound calls. They are useful for cross-cutting transport behavior such as logging, correlation IDs, authentication headers, API keys, or metrics. Because interceptors live at the client boundary, they avoid duplicating the same header or logging code in every method.
This guide uses interceptors for both method-level behavior and reusable client-level authorization.
Targeted Changes¶
Advanced client features can easily spread through an application if the generated client is used everywhere directly. This guide keeps a service wrapper around the client so form calls, multipart calls, custom decoding, and authorization remain near the transport boundary. The rest of the application can work with clearer methods and typed results.
The practical flow is:
- add a dedicated client for the advanced data routes
- call form and multipart endpoints declaratively
- add a custom request mapper for one payload shape
- decode response statuses into typed results
- attach logging and API-key authorization with interceptors
New HTTP Client¶
The first advanced client concept is still very concrete: call the extra routes introduced by DataController.
We keep these calls in a separate DataApiClient so the transport-heavy examples do not clutter the simpler 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>)
}
This separation helps:
UserApiClientstays focused on CRUDDataApiClientbecomes the home for advanced transport examples- the base guide stays easy to read
Parameter Mapper¶
For more on client request-body mappers, see HTTP Client request body.
Sometimes a request body should not use the normal JSON or form mapping flow. A remote endpoint may expect a very specific text or binary representation, and you still want to model the input as your own type.
That is what HttpClientRequestMapper<T> is for.
In this guide we use a small example:
- the method accepts
PlainTextGreetingBody - a mapper turns it into a plain-text HTTP body
- the advanced server echoes that mapped text back
Add these pieces inside 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);
Add the same idea in 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
This is the client-side analogue of the request mappers we introduced in the advanced server guide: a typed object becomes a transport representation in one clear place.
Response Code Mapping¶
Default client behavior often treats a response as either:
- a successful body
- or an exception
That is enough for many APIs. But sometimes the contract intentionally says:
200returns one JSON shape- non-
200responses return another JSON shape
That is where @ResponseCodeMapper becomes useful.
In this guide, GET /data/mapping-by-code/{code} behaves like this:
200returns{"message":"Hello from response mapper"}- other codes return
{"message":"Request failed with code <status>"}through the shared server-sideErrorResponse
We model that as one sealed result type.
Add this inside 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);
Add the same idea in 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
This pattern is valuable because the status-code-specific transport logic stays close to the client method instead of leaking into every caller.
Notice one small but important detail in this version of the example:
- the error JSON body contains only
message - the mapper gets
codefrom the actual HTTP status line
That keeps the server-side error format simpler while still letting the client expose a richer typed result.
Client Interceptor¶
For more on client interceptors, their scope, and execution order, see HTTP Client interceptors.
The next advanced concept is a method-level interceptor.
Interceptors are useful when you want reusable behavior around a call, such as:
- logging
- metrics
- custom transport diagnostics
We keep this example intentionally small and apply it only to getMappedByCode().
Add this inside 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);
Add the same idea in 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
This is a good local-before-global pattern: we add behavior only where the example actually needs it.
API Key Authorization¶
For the broader HTTP client authorization model, see Authorization.
The advanced server guide protected DataController with a simple API-key check on the Authorization header.
At this point we already understand the advanced routes themselves, so now it makes sense to add one more reusable client concern: automatic authorization.
We do not want every caller to remember that header manually. That is exactly the kind of repeated transport rule that belongs in an interceptor.
Create the config contract:
Create the auth interceptor:
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)
}
}
Apply it to DataApiClient:
This is a very common interceptor use case. Teams often use the same pattern for:
Authorizationheaders- cookies
- API keys
- other request metadata that should always be added automatically
Configure the API key:
For the full configuration reference, see Configuration.
Both applications can share the same local default, while HTTP_ADVANCED_API_KEY keeps the example environment-friendly.
Imperative Client¶
Declarative @HttpClient interfaces are the usual application-level style, but Kora also exposes the base HttpClient component. This is useful when you need to build a request dynamically, apply an
interceptor manually, or debug what the declarative client hides from you.
First add a small config contract for the same remote base URL used by DataApiClient:
Now add a small manual client. Notice that it does not put the authorization header directly on the request. It reuses the same auth interceptor through 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)
}
}
}
This example is intentionally small, but it demonstrates three important details:
HttpClientRequest.of(...)builds the outgoing request explicitlyHttpClient.with(...)returns a client decorated with an interceptorexecute(...)is the low-level operation behind higher-level declarative clients
After compilation, the generated application graph shows that Kora wires the base client, config, and interceptor into the manual client:
That generated graph is a useful source of truth when you want to confirm which HttpClient implementation and interceptors are actually injected.
Check Controller¶
Now we wire the advanced client features into one aggregate scenario dedicated only to the DataController routes.
The base guide already has a user-oriented aggregate endpoint. We keep that separation:
testAllUserEndpoints()belongs to the basic client guidetestAllDataEndpoints()belongs to this advanced guide
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?
)
}
Check Application¶
Run the advanced server and the advanced client in separate terminals.
Terminal 1: Server¶
The advanced server app should listen on http://localhost:8080.
Terminal 2: Client¶
The advanced client app should listen on http://localhost:8081.
Client Scenario¶
Expected result: a JSON object where allTestsPassed is true.
Best Practices¶
- Keep the basic HTTP client guide focused on the simplest JSON-first path, and move transport-heavy topics into an advanced follow-up.
- Use separate client interfaces for different remote API areas when that improves readability.
- Reach for
HttpClientRequestMapperonly when the built-in mapping styles are not enough. - Use
@ResponseCodeMapperwhen status-code-aware decoding is part of the contract. - Use interceptors for repeated transport behavior like logging or authorization instead of repeating headers and boilerplate manually.
Summary¶
You extended the basic HTTP client application with:
- a separate
DataApiClient - form and multipart request support
- a custom request mapper
- response-code-aware decoding
- a method-level interceptor
- reusable API-key authorization
The result mirrors the spirit of http-server-advanced.md: one advanced transport concept at a time, each introduced only after the simpler path is already clear.
Key Concepts¶
FormUrlEncodedandFormMultipartare first-class client-side body types in KoraHttpClientRequestMapper<T>lets you control how a type becomes an HTTP request body@ResponseCodeMapperlets different status codes decode into different variants of one result typeHttpClientInterceptoris a good place both for local logging and shared authorization behavior
Troubleshooting¶
Protected calls return 403:
- Check that the server and client use the same API key value.
- Check the
HTTP_ADVANCED_API_KEYenvironment variable on both applications. - Remember that the environment variable overrides the local default from
application.conf.
Form or multipart requests do not work:
- Make sure the advanced server app is running, not only the basic server app.
- Check that
DataControlleris exposed on the target server.
Custom request mapper does not run:
- Make sure the parameter uses
@Mapping(...). - Make sure the mapper implements
HttpClientRequestMapper<T>.
Response-code mapping does not behave as expected:
- Check the
@ResponseCodeMapperentries carefully. - Remember that
ResponseCodeMapper.DEFAULTis the fallback for all unlisted codes. - Make sure the server route returns the JSON shape your mapper expects for each branch.
Interceptor logging does not appear:
- Check
@InterceptWith(...)on the specific client method. - Make sure the interceptor class implements
HttpClientInterceptor.
What's Next?¶
- OpenAPI HTTP Server if you have not completed the contract-first server path yet.
- OpenAPI HTTP Client after OpenAPI HTTP Server, to see how contract generation models similar transport behavior.
- Resilient Patterns to protect advanced outbound calls with retry, timeout, circuit breaker, and fallback.
- Observability to trace interceptors, manual
HttpClientcalls, and mapped responses.
Help¶
If you get stuck:
- compare with Kora Java HTTP Client Advanced App and Kora Kotlin HTTP Client Advanced App
- revisit HTTP Client for the basic declarative client shape
- revisit HTTP Server Advanced for the server endpoints this client calls
- check the HTTP Client documentation