Kora HTTP Server
Kora sub-skill — obey the kora-v2 meta rules on every task: R0 ground the workspace on Kora 2.0 refs before starting (framework source at tag
2.0.0.RC1+kora-examplesatmigration/2.0;kora-docsis 1.x only) · R1 read this sub-skill before writing code · R2 Kora 2.0 APIs only — no Spring/Micronaut/Quarkus, no Kora 1.x APIs, no invented annotations or config keys · R3 journal any incorrect Kora usage. Add comments/Javadoc only if asked.
| Artifact | io.koraframework:http-server-undertow (BOM io.koraframework:kora-bom, 2.0.0.RC1) |
| Graph module | io.koraframework.http.server.undertow.UndertowPublicHttpServerModule |
| Processor | io.koraframework:annotation-processors (Java) · io.koraframework:symbol-processors (KSP) |
| Config sections | httpServer (public) · httpServer.system (probes/metrics) · httpServer.undertow (transport) |
| Execution model | Synchronous handlers on virtual threads. No CompletionStage, no Mono/Flux, no suspend |
Handlers are generated at compile time. For each @HttpController the processor emits a
<Controller>Module @Module interface with one HttpServerRequestHandler factory method per
@HttpRoute, which the DI graph assembles into an HttpServerRouter. Nothing is reflective, and
nothing is discovered at runtime — a route that does not compile does not exist.
Migrating from Kora 1.x — read this first
Five changes cause silent or confusing failures. Each is covered in depth in the linked reference.
| 1.x | 2.0 | If you skip it |
|---|---|---|
@Tag(HttpServerModule.class) on a global interceptor |
@Tag(HttpServer.class) |
Compiles clean, never runs. HttpServerModule still exists so the annotation is legal, but nothing collects interceptors by that tag — the component is pruned from the graph without a warning. Interceptors |
httpServer.publicApiHttpPort / privateApiHttpPort |
httpServer.port / httpServer.system.port |
Starts green on the wrong ports. A stale key is just an unknown HOCON key: ignored silently, so each server falls back to its own default (8080 public, 8085 system) and probes hit nothing. Configuration |
intercept(Context, request, chain) → CompletionStage |
intercept(request, chain) → HttpServerResponse |
Compile error. Context no longer exists anywhere in Kora. Interceptors |
CompletionStage<T> / Mono<T> / suspend fun handlers |
plain synchronous return types | No reactive response mapper exists; KSP rejects suspend outright. Response Types |
writer.toByteArrayUnchecked(v) in a try/catch (IOException) |
writer.toByteArray(v), no catch |
error: exception IOException is never thrown in body of corresponding try statement. Error Handling |
Every framework package moved from ru.tinkoff.kora.* to io.koraframework.*, and the HTTP
packages were also re-split — a blind group rename leaves imports that do not resolve. See
Controller & Routing.
Quick start
1. Dependencies (Java)
configurations {
koraBom
annotationProcessor.extendsFrom(koraBom)
compileOnly.extendsFrom(koraBom)
implementation.extendsFrom(koraBom)
}
dependencies {
koraBom platform("io.koraframework:kora-bom:$koraVersion") // koraVersion=2.0.0.RC1
annotationProcessor "io.koraframework:annotation-processors" // mandatory
implementation "io.koraframework:http-server-undertow"
implementation "io.koraframework:json-common"
implementation "io.koraframework:config-hocon"
implementation "io.koraframework:logging-logback"
}
Kotlin replaces the processor with KSP:
implementation(platform("io.koraframework:kora-bom:${property("koraVersion")}"))
ksp("io.koraframework:symbol-processors:${property("koraVersion")}")
implementation("io.koraframework:http-server-undertow")
Java 25 is the floor — Kora 2.0 artifacts are class-file 69. Artifacts other than the BOM are never versioned individually.
2. Application graph
@KoraApp
public interface Application extends
HoconConfigModule,
LogbackModule,
JsonModule,
UndertowPublicHttpServerModule {
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
UndertowPublicHttpServerModule extends UndertowSystemHttpServerModule, so plugging in the
public module starts both servers: the application server on httpServer.port and the system
server (readiness, liveness, metrics) on httpServer.system.port. UndertowHttpServerModule from
1.x no longer exists.
3. Controller
@Component
@HttpController
public final class HelloController {
@HttpRoute(method = HttpMethod.GET, path = "/hello/{name}")
public String hello(@Path String name) {
return "Hello " + name; // 200 OK, text/plain
}
}
@HttpController optionally takes a path prefix (String value() default ""), prepended to
every @HttpRoute path in the class. Leave it off and each route carries its full path.
4. Config (application.conf)
httpServer {
port = 8080
system.port = 8085
telemetry.logging.enabled = true # off by default
}
CRUD controller
JSON needs @Json on the method and on the body parameter. Optional inputs are @Nullable
(Java, JSpecify) or a nullable type (Kotlin).
@Component
@HttpController
public final class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@HttpRoute(method = HttpMethod.GET, path = "/users/{userId}")
@Json
public UserResponse getUser(@Path String userId) {
return userService.findById(userId)
.orElseThrow(() -> HttpServerResponseException.of(404, "User not found: " + userId));
}
@HttpRoute(method = HttpMethod.GET, path = "/users")
@Json
public List<UserResponse> listUsers(@Nullable @Query Integer page,
@Nullable @Query Integer size) {
return userService.findAll(page == null ? 0 : page, size == null ? 10 : size);
}
@HttpRoute(method = HttpMethod.POST, path = "/users")
@Json
public HttpResponseEntity<UserResponse> createUser(@Json UserRequest request) {
var created = userService.create(request);
return HttpResponseEntity.of(201, HttpHeaders.of("Location", "/users/" + created.id()), created);
}
@HttpRoute(method = HttpMethod.DELETE, path = "/users/{userId}")
public void deleteUser(@Path String userId) {
userService.delete(userId); // void -> 200 with an empty body, no mapper needed
}
}
DTOs are plain records annotated @Json:
UserRequest(String email, String name), UserResponse(String id, String email, String name).
Key annotations
| Annotation | Package | Level | Purpose |
|---|---|---|---|
@HttpController |
io.koraframework.http.server.common.annotation |
class | Marks an HTTP controller (no path argument) |
@HttpRoute(method, path) |
io.koraframework.http.common.annotation |
method | Binds an HTTP method + path to a handler |
@Path |
io.koraframework.http.common.annotation |
parameter | Path segment {name} |
@Query |
io.koraframework.http.common.annotation |
parameter | Query parameter |
@Header |
io.koraframework.http.common.annotation |
parameter | Request header |
@Cookie |
io.koraframework.http.common.annotation |
parameter | Cookie |
@InterceptWith(X.class) |
io.koraframework.http.common.annotation |
class / method | Scoped interceptor (repeatable) |
@Json |
io.koraframework.json.common.annotation |
method / parameter | JSON body |
@Mapping(X.class) |
io.koraframework.common.annotation |
method / parameter | Custom request/response mapper |
@Component, @Tag |
io.koraframework.common.annotation |
class | Graph registration and tagging |
HttpMethod (io.koraframework.http.common.HttpMethod) is a holder of String constants, not
an enum: GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE, PATCH, QUERY.
@HttpRoute(method = ...) takes a String, so method = "GET" is equally valid.
Response types
| Return type | Status | Notes |
|---|---|---|
String |
200 | text/plain |
byte[] / ByteBuffer |
200 | application/octet-stream |
T + @Json on the method |
200 | Serialized with the generated JsonWriter<T> |
HttpResponseEntity<T> (+ @Json) |
any | Body plus status code and headers |
HttpServerResponse |
any | Full control; returned as-is, no mapper involved |
void (Kotlin Unit) |
200 | Empty body; no response mapper required |
any other T |
200 | Needs an HttpServerResponseMapper<T> in the graph |
return HttpResponseEntity.of(201, HttpHeaders.of("Location", "/users/" + id), user);
return HttpServerResponse.of(200, HttpHeaders.of("X-Trace", traceId), HttpBody.plaintext("OK"));
return HttpServerResponse.of(204);
Reactive and suspending handlers are gone. http-server-common ships no response mapper for
Mono/Flux/CompletionStage, and the Kotlin processor rejects suspend with
Suspend methods are not supported by the HTTP server controller generator. Run concurrent work
inside a synchronous handler with StructuredTaskScope.
See Response Types for HttpBody, HttpHeaders
and custom HttpServerResponseMapper (including the Kotlin nullability rule).
Error handling
Throw HttpServerResponseException.of(code, message) to short-circuit a handler.
HttpServerResponseException is itself an HttpServerResponse, so an interceptor can simply
return it. There is no @ControllerAdvice equivalent — centralized error translation is an
interceptor concern.
return userService.find(id)
.orElseThrow(() -> HttpServerResponseException.of(404, "Not found: " + id));
See Error Handling for the global error interceptor.
Interceptors
public interface HttpServerInterceptor {
HttpServerResponse intercept(HttpServerRequest request, InterceptChain chain) throws Exception;
interface InterceptChain {
HttpServerResponse process(HttpServerRequest request) throws Exception;
}
}
Synchronous, one request argument, throws Exception — so a plain try/catch around
chain.process(request) is the way to handle downstream errors.
| Scope | How | Applies to |
|---|---|---|
| Server | @Tag(HttpServer.class) + @Component |
Every route on the public server. Any number of them |
| Controller | @InterceptWith(X.class) on the controller class |
Every route in that controller |
| Method | @InterceptWith(X.class) on a @HttpRoute method |
That route only |
@Tag(HttpServer.class) // io.koraframework.http.server.common.HttpServer
@Component
public final class LoggingInterceptor implements HttpServerInterceptor {
private static final Logger log = LoggerFactory.getLogger(LoggingInterceptor.class);
@Override
public HttpServerResponse intercept(HttpServerRequest request, InterceptChain chain) throws Exception {
long started = System.nanoTime();
HttpServerResponse response = chain.process(request);
log.info("{} {} -> {} ({} ms)", request.method(), request.path(), response.code(),
(System.nanoTime() - started) / 1_000_000);
return response;
}
}
Server-scoped interceptors are sorted by simple class name, not by declaration order, and they never run on the system server. See Interceptors.
Configuration
httpServer {
port = 8080
ignoreTrailingSlash = false
shutdownWait = "30s"
maxRequestBodySize = "256MiB"
telemetry {
logging.enabled = true # default false
metrics.enabled = true # default false
tracing.enabled = true # default true (but false under httpServer.system)
}
system {
port = 8085
metricsPath = "/metrics"
readinessPath = "/system/readiness"
livenessPath = "/system/liveness"
}
undertow {
ioThreads = 4 # transport tuning lives in its own section
}
}
telemetry.logging.enabled and telemetry.metrics.enabled default to false everywhere — an
example that claims to demonstrate them must switch them on. See
Configuration for every key that actually exists.
References
| Reference | Covers |
|---|---|
| Controller & Routing | @HttpController, @HttpRoute, package map, generated code, routing/405/404 |
| Request Mapping | @Path/@Query/@Header/@Cookie, supported types, bodies, forms, HttpServerRequestMapper |
| Response Types | HttpServerResponse, HttpResponseEntity, HttpBody, response mappers, @Component rules |
| Interceptors | HttpServerInterceptor, tag/scope, order, request enrichment |
| Error Handling | HttpServerResponseException, global error interceptor, JSON error bodies |
| Configuration | httpServer, httpServer.system, httpServer.undertow, telemetry, port migration |
| Authentication | Where auth lives in 2.0 and how it plugs into this server |
| Request Enrichment | Passing values from an interceptor to a handler now that Context is gone |
Assets
| Template | Purpose |
|---|---|
| UserController.java | CRUD controller (Java) |
| UserController.kt | CRUD controller (Kotlin) |
| LoggingInterceptor.java | Server-scoped logging interceptor (Java) |
| LoggingInterceptor.kt | Server-scoped logging interceptor (Kotlin) |
| ErrorInterceptor.java | Server-scoped error-to-JSON interceptor (Java) |
| ErrorInterceptor.kt | Server-scoped error-to-JSON interceptor (Kotlin) |
scripts/validate_controller.py statically checks a controller or interceptor file for the
regressions above (--json for machine output, --help for usage).
Pitfalls
| Symptom | Cause and fix |
|---|---|
| Global interceptor never runs, no error | Tagged @Tag(HttpServerModule.class) or left untagged. Both compile and are silently pruned. Use @Tag(HttpServer.class) |
| Service starts fine but probes/LB hit nothing | Stale publicApiHttpPort/privateApiHttpPort. Unknown keys are ignored; rename to port / system.port |
Address already in use at startup |
Stale keys collapsed both servers onto one port |
No component found for dependency: X on a mapper |
@Mapping(X.class) injects the concrete class — X must be @Component, even with no constructor arguments |
Multiple components match dependency: HttpServerResponseMapper<T> |
Two untagged @Component mappers for the same T. Keep one, or separate with @Tag |
'apply' overrides nothing (Kotlin) |
Kora contracts are @NullMarked: HttpServerResponseMapper<T>.apply takes result: T? |
Suspend methods are not supported… |
Remove suspend; use StructuredTaskScope for concurrency |
exception IOException is never thrown… |
JsonWriter.toByteArray no longer declares a checked exception — delete the catch |
| 404 on a valid URL | {var} has no matching @Path argument, or the @HttpController prefix is not what you think — in Kotlin the prefix is concatenated raw, so it must start with / and must not end with one |
Cannot add path template …, matcher already contains an equivalent pattern |
Two routes on one method resolve to the same template (e.g. /a/{x} and /a/{y}) |
| Metrics endpoint returns nothing useful | telemetry.metrics.enabled defaults to false — enable it explicitly |
Evals
Self-check rubric: evals/evals.json.