From 7f67dba9195aa46098a815063cae32fe55822905 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Wed, 20 May 2026 14:30:31 +0200 Subject: [PATCH] chore: Restructure README for navigation and de-dup --- README.md | 555 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 354 insertions(+), 201 deletions(-) diff --git a/README.md b/README.md index 29fa78e..cc0fa8e 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,45 @@ [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=extenda_openapi-httpserver-java&metric=duplicated_lines_density&token=c87f52089c6158081787f26e272d0a0e412c205b)](https://sonarcloud.io/dashboard?id=extenda_openapi-httpserver-java) [![WorkFlow](https://github.com/extenda/openapi-httpserver-java/actions/workflows/commit.yaml/badge.svg)](https://github.com/extenda/openapi-httpserver-java/actions) - -# OpenAPI Server Library -A lightweight Java library for creating HTTP servers based on OpenAPI specifications. - - -## Overview -This library provides a simple way to create an HTTP server that implements OpenAPI specifications. - -It is designed to be simple to use while providing the essential features needed for creating efficient HTTP servers in Java. +A lightweight Java library that wraps the JDK's `com.sun.net.httpserver.HttpServer` and serves +endpoints declared in an OpenAPI 3.1.x specification. Handlers are pure functions registered by +`operationId`; the framework handles routing, OpenAPI parameter and body validation, JSON +(de)serialisation, and RFC 7807 error rendering. + +## Table of contents + +- [Highlights](#highlights) +- [Maven artifact](#maven-artifact) +- [Quick start](#quick-start) +- [Spec loading](#spec-loading) +- [JSON mapping](#json-mapping) +- [Body parsers and response writers](#body-parsers-and-response-writers) +- [Server configuration](#server-configuration) +- [Interceptors and response decorators](#interceptors-and-response-decorators) +- [After-response hooks](#after-response-hooks) +- [Security](#security) +- [Request body content types](#request-body-content-types) +- [Error responses (RFC 7807)](#error-responses-rfc-7807) +- [Extra (non-OpenAPI) handlers](#extra-non-openapi-handlers) +- [Graceful shutdown](#graceful-shutdown) +- [End-to-end example](#end-to-end-example) +- [Local development](#local-development) +- [Performance](#performance) +- [Caveats](#caveats) + +## Highlights + +- OpenAPI 3.1.x driven routing, parameter validation, and request-body validation +- Pure-function handlers: `Response handle(Request)` — no `HttpExchange` plumbing +- Per-media-type `TypeMapper` contract for request parsing and response writing +- Built-in `GsonJsonMapper` auto-registered when Gson is on the classpath; Jackson 2.x and 3.x + adapters available +- `RequestInterceptor` for around-style concerns (`ScopedValue`, MDC, auth, tracing); composable + `ResponseDecorator` for cross-cutting response headers +- OpenAPI `securitySchemes` and `security` enforcement (`apiKey`, `http bearer`, `http basic`), + with an opt-out for sidecar / gateway authentication +- RFC 7807 `application/problem+json` validation errors with JSON-Pointer to the failing location +- Built on the JDK's native `HttpServer` with thread-per-request behaviour using virtual threads ## Maven artifact @@ -26,22 +56,23 @@ It is designed to be simple to use while providing the essential features needed ``` -## Getting Started +## Quick start ### Prerequisites + - Java SDK 25 or later. -- A JSON library to parse the spec into a `Map`: any of Gson, Jackson, SnakeYAML (for YAML specs), or another mapper of your choice. The library itself doesn't bundle one. +- A JSON or YAML library to parse the spec into a `Map` — Gson, Jackson, or + SnakeYAML are all supported. The library itself doesn't bundle one. - An OpenAPI 3.1.x specification (`openapi.json` or `openapi.yaml`). -- For `application/json` request/response bodies, either: - - Gson on the classpath — auto-registered via the built-in `GsonJsonMapper` (integer-preserving, JSR-310 written as ISO-8601), or - - Jackson via the built-in adapters — `Jackson2JsonTypeMapper(ObjectMapper)` for Jackson 2.x (`com.fasterxml.jackson.*`) or `Jackson3JsonTypeMapper(ObjectMapper)` for Jackson 3.x (`tools.jackson.*`). Caller supplies a configured `ObjectMapper`; the two adapters use disjoint package roots and can coexist on the same classpath. - - any other `TypeMapper` you register via `Builder.jsonMapper(mapper)` (shortcut for `bodyMapper("application/json", mapper)`). -- Built-in mappers for `application/x-www-form-urlencoded` and `text/plain` need no configuration. Any other media type (`application/xml`, `application/cbor`, etc.) requires registering its own `TypeMapper`. +- A `TypeMapper` for any media type your operations use. See + [JSON mapping](#json-mapping) and [Body parsers and response writers](#body-parsers-and-response-writers) + for the built-ins and how to register your own. +### 1. Define handlers + +Handlers implement the `RequestHandler` functional interface. They consume a `Request` and return +a `Response`; the framework renders status code, headers, and body for you. -### Basic Usage -1. Create an OpenAPI specification file named `openapi.json` in your project resources. -2. Define your handlers using the `RequestHandler` functional interface. Handlers are pure functions: they consume a `Request` and return a `Response`. The framework renders the response (status code, headers, body) for you. ``` java // Inline lambda — returns JSON using the built-in Gson mapper. RequestHandler getDataHandler = req -> Response.ok(Map.of("id", "some-id")); @@ -67,8 +98,6 @@ public class PostDataHandler implements RequestHandler { } ``` -### Building responses - `Response` is an immutable record built via static factories. Pick the one that fits: ``` java @@ -100,16 +129,21 @@ return Response.ok(payload) .withContentType("application/vnd.example+json"); ``` -A `null` body always produces a status-only response (`Content-Length: 0`, no body bytes), regardless of status code. Streaming bodies bypass `TypeMapper` entirely; one-shot object bodies (`ok`, `of`) are serialised by the `TypeMapper` registered for the response's content type (default `application/json`). +A `null` body always produces a status-only response (`Content-Length: 0`, no body bytes), +regardless of status code. Streaming bodies bypass `TypeMapper` entirely; one-shot object bodies +(`ok`, `of`) are serialised by the `TypeMapper` registered for the response's content type +(default `application/json`). + +### 2. Start the server + +Handlers are registered in a `Map` keyed by OpenAPI `operationId`. -3. Initialize the server: ``` java public class YourServerLauncher { public static void main(String[] args) throws Exception { // Gson is on the classpath, so we can load the spec in one line. Spec spec = Spec.fromPath(Path.of("openapi.json")); - // Handlers by operationId. Map handlers = new HashMap<>(); handlers.put("get-data", getDataHandler); handlers.put("post-data", new PostDataHandler()); @@ -123,7 +157,13 @@ public class YourServerLauncher { } ``` -`Spec.fromPath(Path)` picks the parser by file extension: `.json` is parsed by Gson, `.yaml` / `.yml` by SnakeYAML. Both are optional dependencies of this library — the same Gson that powers the built-in JSON `TypeMapper`, and the same SnakeYAML you'd add explicitly to parse YAML. If the required parser isn't on the classpath the call fails with `IllegalStateException`; parse the file yourself and use `Spec.from(Map)` instead. Any other extension is rejected. +## Spec loading + +`Spec.fromPath(Path)` picks the parser by file extension: `.json` is parsed by Gson, `.yaml` / +`.yml` by SnakeYAML. Both are optional dependencies — the same Gson that powers the built-in JSON +`TypeMapper`, and the same SnakeYAML you'd add explicitly to parse YAML. If the required parser +isn't on the classpath the call fails with `IllegalStateException`; parse the file yourself and +use `Spec.from(Map)` instead. Any other extension is rejected. To load a spec from the classpath (including from inside a JAR) use the `InputStream` overloads: @@ -134,7 +174,8 @@ try (InputStream in = YourServerLauncher.class.getResourceAsStream("/openapi.jso } ``` -The matching `Spec.fromYaml(InputStream)` uses SnakeYAML. Both close the stream before returning. If you can't (or don't want to) depend on Gson, supply your own JSON parser: +The matching `Spec.fromYaml(InputStream)` uses SnakeYAML. Both close the stream before returning. +If you can't (or don't want to) depend on Gson, supply your own JSON parser: ``` java ObjectMapper jackson = new ObjectMapper(); @@ -144,17 +185,23 @@ try (InputStream in = YourServerLauncher.class.getResourceAsStream("/openapi.jso } ``` -YAML always parses through SnakeYAML — there's no parser-injecting overload. If you want a different YAML library, decode the stream yourself and call `Spec.from(Map)`. +YAML always parses through SnakeYAML — there's no parser-injecting overload. If you want a +different YAML library, decode the stream yourself and call `Spec.from(Map)`. -### JSON mapping +## JSON mapping -The library ships an internal `GsonJsonMapper` that is auto-registered for `application/json` when Gson is on the classpath and no user-supplied JSON mapper has been registered. It: +The library ships an internal `GsonJsonMapper` that is auto-registered for `application/json` +when Gson is on the classpath and no user-supplied JSON mapper has been registered. It: -- Returns JSON integers as `Long` and fractional numbers as `Double` for the loose `request.parsed()` view. -- For `request.asPojo(MyDto.class)`, delegates to Gson — the target type's fields determine the Java types (`int`, `long`, `Instant`, etc.). -- Round-trips JSR-310 types (`Instant`, `OffsetDateTime`, `ZonedDateTime`, `LocalDateTime`, `LocalDate`, `LocalTime`) as their ISO-8601 string form. +- Returns JSON integers as `Long` and fractional numbers as `Double` for the loose + `request.parsed()` view. +- For `request.asPojo(MyDto.class)`, delegates to Gson — the target type's fields determine the + Java types (`int`, `long`, `Instant`, etc.). +- Round-trips JSR-310 types (`Instant`, `OffsetDateTime`, `ZonedDateTime`, `LocalDateTime`, + `LocalDate`, `LocalTime`) as their ISO-8601 string form. -To customize Gson, wire `GsonTypeMapper` explicitly. The no-arg form uses the same JSR-310-aware default as auto-registration; pass a `Gson` to fully control serialization: +To customize Gson, wire `GsonTypeMapper` explicitly. The no-arg form uses the same JSR-310-aware +default as auto-registration; pass a `Gson` to fully control serialization: ```java var server = OpenApiServer.builder() @@ -164,7 +211,8 @@ var server = OpenApiServer.builder() .build(); ``` -To extend the library default (instead of building a `Gson` from scratch), unwrap it via `gsonBuilder()`: +To extend the library default (instead of building a `Gson` from scratch), unwrap it via +`gsonBuilder()`: ```java Gson custom = @@ -180,7 +228,9 @@ var server = OpenApiServer.builder() .build(); ``` -For Jackson, the library ships two adapters that wrap an `ObjectMapper` you configure (modules, naming strategy, JSR-310, date formats — all your call). Pick the one that matches your Jackson major: +For Jackson, the library ships two adapters that wrap an `ObjectMapper` you configure (modules, +naming strategy, JSR-310, date formats — all your call). The two adapters use disjoint package +roots and can coexist on the same classpath; pick the one that matches your Jackson major: ```java // Jackson 2.x (group: com.fasterxml.jackson.core) @@ -214,13 +264,19 @@ var server = OpenApiServer.builder() .build(); ``` -Jackson 3 made all I/O exceptions unchecked (`tools.jackson.core.JacksonException extends RuntimeException`), so `Jackson3JsonTypeMapper` propagates read/write failures as-is. `Jackson2JsonTypeMapper` wraps Jackson 2's checked `IOException` in `UncheckedIOException`. +Jackson 3 made all I/O exceptions unchecked +(`tools.jackson.core.JacksonException extends RuntimeException`), so `Jackson3JsonTypeMapper` +propagates read/write failures as-is. `Jackson2JsonTypeMapper` wraps Jackson 2's checked +`IOException` in `UncheckedIOException`. -The same shape applies to any custom mapper — implement `TypeMapper` (and optionally `TypedTypeMapper` if you can deserialise directly into a target type, so handlers can call `request.asPojo(MyDto.class)`). +The same shape applies to any custom mapper — implement `TypeMapper` (and optionally +`TypedTypeMapper` if you can deserialise directly into a target type, so handlers can call +`request.asPojo(MyDto.class)`). -If neither Gson is on the classpath nor any `application/json` mapper is registered, `build()` throws `IllegalStateException`. +If neither Gson is on the classpath nor any `application/json` mapper is registered, `build()` +throws `IllegalStateException`. -### Body parsers and response writers +## Body parsers and response writers `TypeMapper` is the per-media-type read/write contract: @@ -231,21 +287,32 @@ public interface TypeMapper { } ``` -Register a custom mapper for any media type via `Builder.bodyMapper(mediaType, mapper)`. Built-in defaults: +Register a custom mapper for any media type via `Builder.bodyMapper(mediaType, mapper)`. The +shortcut `Builder.jsonMapper(mapper)` is equivalent to `bodyMapper("application/json", mapper)`. + +Built-in defaults: -- `application/x-www-form-urlencoded` — read-only. Produces `Map`. A single value is a `String`; repeated keys produce a `List`. +- `application/x-www-form-urlencoded` — read-only. Produces `Map`. A single value + is a `String`; repeated keys produce a `List`. - `text/plain` — read and write. Produces a decoded `String`; writes via `String.getBytes()`. -- `application/json` — auto-registered when Gson is on the classpath (see above). +- `application/json` — auto-registered when Gson is on the classpath (see + [JSON mapping](#json-mapping)). + +User-supplied mappers take precedence over built-in defaults, so you can override any of the +above. Any other media type (`application/xml`, `application/cbor`, etc.) requires registering +its own `TypeMapper`. -User-supplied mappers take precedence over built-in defaults, so you can override any of the above. +## Server configuration -#### Listen port +### Listen port -`Builder.port(int)` is optional and defaults to `8080`. Pass `0` to bind on an ephemeral port and read the actual port back via `OpenApiServer.listenPort()` — useful for tests. +`Builder.port(int)` is optional and defaults to `8080`. Pass `0` to bind on an ephemeral port and +read the actual port back via `OpenApiServer.listenPort()` — useful for tests. -#### Restricting to the loopback interface +### Bind address -By default the server binds to the wildcard address (all local interfaces). To restrict it to loopback — useful for local development or sidecar processes — supply a bind address: +By default the server binds to the wildcard address (all local interfaces). To restrict it to +loopback — useful for local development or sidecar processes — supply a bind address: ```java import java.net.InetAddress; @@ -258,9 +325,35 @@ OpenApiServer.builder() .build(); ``` +### Graceful shutdown + +`OpenApiServer` exposes `stop(int delaySeconds)` for explicit shutdown that waits up to the given +number of seconds for in-flight exchanges to complete before closing them. `0` stops immediately. +The same drain timeout can be wired into `close()` (and therefore try-with-resources) via the +builder: + +```java +try (var server = OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .shutdownTimeoutSeconds(5) // close() drains up to 5s; default is 0 + .build()) { + // serve requests... +} // close() now waits up to 5s for in-flight exchanges +``` + +`stop(int)` and `shutdownTimeoutSeconds(int)` reject negative values with +`IllegalArgumentException`. + +## Interceptors and response decorators + ### Response decorators -`Builder.responseDecorator(...)` registers a `ResponseDecorator` — a `(Request, Response) -> Response` transform applied to every handler's return value before rendering. Decorators compose in registration order: the result of one is fed to the next. Decorator-supplied headers override handler-supplied ones; if you want the opposite, set the header inside the handler with `Response.withHeader(...)`. +`Builder.responseDecorator(...)` registers a `ResponseDecorator` — a +`(Request, Response) -> Response` transform applied to every handler's return value before +rendering. Decorators compose in registration order: the result of one is fed to the next. +Decorator-supplied headers override handler-supplied ones; if you want the opposite, set the +header inside the handler with `Response.withHeader(...)`. ``` java OpenApiServer.builder() @@ -273,7 +366,11 @@ OpenApiServer.builder() ### Request interceptors -`Builder.interceptor(...)` registers a `RequestInterceptor` that wraps every handler invocation. Use it for `ScopedValue` bindings, MDC, authentication, tracing, or any concern that needs to run uniformly around handlers. Interceptors compose in registration order: the first registered runs outermost. Each interceptor must call `next.proceed()` and return the result (or a transformed `Response`). +`Builder.interceptor(...)` registers a `RequestInterceptor` that wraps every handler invocation. +Use it for `ScopedValue` bindings, MDC, authentication, tracing, or any concern that needs to run +uniformly around handlers. Interceptors compose in registration order: the first registered runs +outermost. Each interceptor must call `next.proceed()` and return the result (or a transformed +`Response`). ``` java OpenApiServer.builder() @@ -297,9 +394,10 @@ OpenApiServer.builder() Exceptions propagate to the library's standard `ExceptionFilter` and `ExceptionHandler` pipeline. -### Combining interceptors and decorators +### Combining the two -The two collaborate naturally: the interceptor binds per-request context once, and the decorator reads that context when stamping response headers. Handlers stay pure business logic. +The two collaborate naturally: the interceptor binds per-request context once, and the decorator +reads that context when stamping response headers. Handlers stay pure business logic. ``` java // Per-request context populated by the interceptor, read by the decorator and handlers. @@ -325,7 +423,9 @@ OpenApiServer.builder() .build(); ``` -Decorators run inside the interceptor's `ScopedValue` binding (the decorator transforms the `Response` returned by `next.proceed()`, which is still on the call stack), so `CORRELATION_ID.get()` / `TENANT_ID.get()` see the bound values. +Decorators run inside the interceptor's `ScopedValue` binding (the decorator transforms the +`Response` returned by `next.proceed()`, which is still on the call stack), so +`CORRELATION_ID.get()` / `TENANT_ID.get()` see the bound values. A handler in this setup is just business logic: @@ -343,7 +443,7 @@ public class GetPromotionHandler implements RequestHandler { } ``` -### After-response hooks +## After-response hooks Register code to run after the response has been sent. Hooks run on the request virtual thread, inside the library's request scope, with exceptions swallowed. @@ -368,71 +468,16 @@ Map handlers = Map.of( ``` Global hooks run first (registration order), then per-request runnables (FIFO). Pre-request -failures (404, 405, validation) do not fire hooks. On the error path (when a handler throws), `Response#body()` is `null` and the bytes have already been streamed; use `Response#status()` to detect errors. - -### End-to-end example - -Gson on the classpath for request/response JSON, SnakeYAML on the classpath for the spec, one interceptor binding a request-scoped tenant + correlation id, one decorator stamping the correlation id on every response, one handler. No extra wiring. - -``` java -package com.example.promotions; - -import com.retailsvc.http.OpenApiServer; -import com.retailsvc.http.Request; -import com.retailsvc.http.RequestHandler; -import com.retailsvc.http.Response; -import com.retailsvc.http.spec.Spec; -import java.nio.file.Path; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; +failures (404, 405, validation) do not fire hooks. On the error path (when a handler throws), +`Response#body()` is `null` and the bytes have already been streamed; use `Response#status()` +to detect errors. -public final class App { +## Security - static final ScopedValue TENANT = ScopedValue.newInstance(); - static final ScopedValue CORRELATION_ID = ScopedValue.newInstance(); - - public static void main(String[] args) throws Exception { - Spec spec = Spec.fromPath(Path.of("openapi.yaml")); // SnakeYAML parses the spec - - RequestHandler getPromotion = req -> { - String id = req.pathParam("id"); - return PromotionService.find(TENANT.get(), id) // uses bound tenant - .map(Response::ok) // 200 + JSON via Gson - .orElseGet(Response::notFound); // 404, no body - }; - - OpenApiServer.builder() - .spec(spec) - .handlers(Map.of("get-promotion", getPromotion)) - // Bind tenant + correlation id once per request. - .interceptor((req, next) -> { - String tenant = req.header("X-Tenant-Id").orElse("public"); - String correlationId = - req.header("X-Correlation-Id").orElseGet(() -> UUID.randomUUID().toString()); - return ScopedValue.where(TENANT, tenant) - .where(CORRELATION_ID, correlationId) - .call(next::proceed); - }) - // Stamp the correlation id on every response. - .responseDecorator((req, resp) -> resp.withHeader("X-Correlation-Id", CORRELATION_ID.get())) - .port(8080) - .build(); - } -} -``` - -What the example demonstrates: - -- **Gson is the default JSON serializer.** No explicit `bodyMapper(...)` call — the library auto-registers `GsonJsonMapper` for request and response JSON because Gson is on the classpath. -- **SnakeYAML parses the spec.** `Spec.fromPath(...)` picks the parser by file extension; `.yaml` here means SnakeYAML, and Gson would handle `.json` the same way. -- **One interceptor sets cross-cutting context.** `ScopedValue.where(...).call(next::proceed)` runs the handler (and any inner interceptors and decorators) inside the binding, so `TENANT.get()` and `CORRELATION_ID.get()` work anywhere they're called. -- **One decorator stamps a response header.** `Response.withHeader(...)` is non-destructive — the handler's `Response` is replaced with one that has the extra header. -- **Handler is a pure function.** Reads from `Request`, returns a `Response` value. No `HttpExchange`, no try/catch IOException, no builder. - -### Security (OpenAPI `securitySchemes` + `security`) - -The library parses `components.securitySchemes` and the `security` requirement lists (root-level and per-operation), extracts the credential per scheme, hands it to a consumer-provided `SchemeValidator` callback, and renders RFC 7807 `application/problem+json` rejections — 401 for missing/malformed credentials (with `WWW-Authenticate`), 403 when the validator denies. +The library parses `components.securitySchemes` and the `security` requirement lists (root-level +and per-operation), extracts the credential per scheme, hands it to a consumer-provided +`SchemeValidator` callback, and renders RFC 7807 `application/problem+json` rejections — 401 for +missing/malformed credentials (with `WWW-Authenticate`), 403 when the validator denies. Supported scheme types in this release: @@ -440,9 +485,11 @@ Supported scheme types in this release: - `http` `bearer` - `http` `basic` -`oauth2`, `openIdConnect`, and `mutualTLS` are parsed into a placeholder type (`SecurityScheme.Unsupported`) — if any operation actually *references* one of those scheme names, the server fails at boot. +`oauth2`, `openIdConnect`, and `mutualTLS` are parsed into a placeholder type +(`SecurityScheme.Unsupported`) — if any operation actually *references* one of those scheme +names, the server fails at boot. -#### Declaring schemes in the spec +### Declaring schemes in the spec ```yaml components: @@ -473,9 +520,11 @@ paths: "200": { description: ok } ``` -`security: []` on an operation means "no security required" (overrides the root default). Omitting `security` on an operation inherits the root default. +`security: []` on an operation means "no security required" (overrides the root default). +Omitting `security` on an operation inherits the root default. -When several entries appear in `security`, they are OR-ed; the request is allowed if *any* entry's schemes all validate. Multiple keys *inside* one entry are AND-ed: +When several entries appear in `security`, they are OR-ed; the request is allowed if *any* +entry's schemes all validate. Multiple keys *inside* one entry are AND-ed: ```yaml security: @@ -486,7 +535,7 @@ security: tenantAuth: [] ``` -#### Registering validators +### Registering validators ```java import com.retailsvc.http.Credential; @@ -516,7 +565,9 @@ OpenApiServer.builder() .build(); ``` -The library guarantees the `Credential` variant matches the scheme's declared type — `apiKey` schemes deliver `ApiKeyCredential`, `http` `bearer` delivers `BearerCredential`, `http` `basic` delivers `BasicCredential`. Pattern matching is cleaner than casts: +The library guarantees the `Credential` variant matches the scheme's declared type — `apiKey` +schemes deliver `ApiKeyCredential`, `http` `bearer` delivers `BearerCredential`, `http` `basic` +delivers `BasicCredential`. Pattern matching is cleaner than casts: ```java .securityValidator("multi", (request, credential) -> switch (credential) { @@ -526,9 +577,12 @@ The library guarantees the `Credential` variant matches the scheme's declared ty }) ``` -#### Constructing the principal +### Constructing the principal -A *principal* is whatever the library hands back to the handler after a successful authentication. The library does NOT define a `Principal` type — your validator returns `Optional` and the library stashes the value on the `Request` under the scheme name. **Whatever you return becomes your principal.** +A *principal* is whatever the library hands back to the handler after a successful +authentication. The library does NOT define a `Principal` type — your validator returns +`Optional` and the library stashes the value on the `Request` under the scheme name. +**Whatever you return becomes your principal.** Three common patterns: @@ -553,7 +607,8 @@ public Response handle(Request request) { } ``` -**2. A `Map` of claims.** Useful when the shape is dynamic or you want to forward JWT claims as-is. +**2. A `Map` of claims.** Useful when the shape is dynamic or you want to forward +JWT claims as-is. ```java .securityValidator("bearerAuth", (request, credential) -> @@ -577,21 +632,34 @@ String sub = (String) claims.get("sub"); String userId = (String) request.principal("apiKeyAuth").orElseThrow(); ``` -If your operation requires multiple schemes simultaneously (AND-group), all principals are stashed under their scheme names: +If your operation requires multiple schemes simultaneously (AND-group), all principals are +stashed under their scheme names: ```java Map principals = request.principals(); // {"bearerAuth": claims, "tenantAuth": tenant} ``` -Returning `Optional.empty()` from a validator means "deny" — the library then returns 403 Forbidden (or 401 if no scheme produced a valid credential at all). Throwing from a validator propagates to the configured `ExceptionHandler`; it does NOT count as deny, so let your validators throw on internal errors and return `Optional.empty()` only when the credential is genuinely invalid. +Returning `Optional.empty()` from a validator means "deny" — the library then returns 403 +Forbidden (or 401 if no scheme produced a valid credential at all). Throwing from a validator +propagates to the configured `ExceptionHandler`; it does NOT count as deny, so let your +validators throw on internal errors and return `Optional.empty()` only when the credential is +genuinely invalid. -#### Boot-time validation +### Boot-time validation -If `security` references a scheme that has no registered `securityValidator(...)`, is undeclared in `components.securitySchemes`, or uses an unsupported type, `OpenApiServer.builder()...build()` throws `IllegalStateException` immediately. You can't ship a server that's missing an auth check by accident — the failure is loud at startup, not silent at request time. +If `security` references a scheme that has no registered `securityValidator(...)`, is undeclared +in `components.securitySchemes`, or uses an unsupported type, +`OpenApiServer.builder()...build()` throws `IllegalStateException` immediately. You can't ship a +server that's missing an auth check by accident — the failure is loud at startup, not silent at +request time. -#### Opt-out: external authentication +### Opt-out: external authentication -In some deployments authentication happens upstream — for example, an Envoy sidecar with OPA, or an API Gateway like Apigee that already verified the credential before the request reaches your JVM. In that case the credential never arrives in a form the library can validate (or the library would be re-validating something the gateway already proved), and forcing you to register stub validators is just friction. +In some deployments authentication happens upstream — for example, an Envoy sidecar with OPA, or +an API Gateway like Apigee that already verified the credential before the request reaches your +JVM. In that case the credential never arrives in a form the library can validate (or the +library would be re-validating something the gateway already proved), and forcing you to +register stub validators is just friction. `useExternalAuthentication()` opts the entire library out of in-process enforcement: @@ -605,9 +673,14 @@ OpenApiServer.builder() Effects when set: -- `SecurityFilter` short-circuits to the next chain step regardless of any `security` declarations — every request reaches the handler. -- The boot-time validator-registration check is skipped, so you don't have to register `.securityValidator(...)` callbacks at all. -- `Request.principals()` returns an empty map; `Request.principal(name)` returns `Optional.empty()`. **The library never reads sidecar-set headers.** If you want a principal in the handler, write a normal `RequestInterceptor` that reads whatever header the sidecar sets and binds a `ScopedValue` (or stashes on the request via a domain wrapper of your own). +- `SecurityFilter` short-circuits to the next chain step regardless of any `security` + declarations — every request reaches the handler. +- The boot-time validator-registration check is skipped, so you don't have to register + `.securityValidator(...)` callbacks at all. +- `Request.principals()` returns an empty map; `Request.principal(name)` returns + `Optional.empty()`. **The library never reads sidecar-set headers.** If you want a principal in + the handler, write a normal `RequestInterceptor` that reads whatever header the sidecar sets + and binds a `ScopedValue` (or stashes on the request via a domain wrapper of your own). Typical sidecar pattern: @@ -625,11 +698,15 @@ OpenApiServer.builder() .build(); ``` -The library still parses `components.securitySchemes` and exposes it via `spec.securitySchemes()` — useful if you serve the OpenAPI document or wire a docs UI — it just stops short of *enforcing* anything. +The library still parses `components.securitySchemes` and exposes it via +`spec.securitySchemes()` — useful if you serve the OpenAPI document or wire a docs UI — it just +stops short of *enforcing* anything. -### Request body content types +## Request body content types -The server reads `requestBody.content` from the spec and selects a mapper by the request's media type (the bare `type/subtype` from `Content-Type`, e.g. `application/json`; lookup is case-insensitive): +The server reads `requestBody.content` from the spec and selects a mapper by the request's media +type (the bare `type/subtype` from `Content-Type`, e.g. `application/json`; lookup is +case-insensitive): | Content type | Parser | Coercion | | ------------------------------------- | ---------------------------------------------------------------------------- | -------- | @@ -637,13 +714,19 @@ The server reads `requestBody.content` from the spec and selects a mapper by the | `application/x-www-form-urlencoded` | Built-in. `Map`. A single value is a `String`; repeated keys produce a `List`. After coercion the element type tracks the schema (e.g. an `integer` array yields `List`). | Yes — field values coerced to the property schema type (integer / number / boolean / array of those) | | `text/plain` | Built-in. Decoded `String` | No — schema should be `type: string` | -Form-field coercion mirrors the rules already used at the parameter boundary: the wire is string-only by definition, so a property typed as `integer` accepts `"42"` and yields `42`. Coercion failures surface as RFC-7807 `400` responses with a JSON-pointer to the failing field. +Form-field coercion mirrors the rules already used at the parameter boundary: the wire is +string-only by definition, so a property typed as `integer` accepts `"42"` and yields `42`. +Coercion failures surface as RFC-7807 `400` responses with a JSON-pointer to the failing field. -Both built-in parsers honour the `charset=` parameter on the `Content-Type` header (default UTF-8). Unknown charsets fall back to UTF-8. +Both built-in parsers honour the `charset=` parameter on the `Content-Type` header (default +UTF-8). Unknown charsets fall back to UTF-8. -### Error responses (RFC 7807) +## Error responses (RFC 7807) -Validation failures — missing required fields, type mismatches, unsupported content types, coercion errors, malformed bodies — produce an `HTTP 400 Bad Request` response with body media type `application/problem+json`, following [RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807). +Validation failures — missing required fields, type mismatches, unsupported content types, +coercion errors, malformed bodies — produce an `HTTP 400 Bad Request` response with body media +type `application/problem+json`, following +[RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807). A single error is reported per request (first failure wins). The response body has these fields: @@ -672,16 +755,20 @@ Example body for `POST /form-echo` with `age=abc` (`age` is declared as `integer Other error responses: - **404 Not Found** — no route matches the request path (no body). -- **405 Method Not Allowed** — path matches but the HTTP method isn't declared. Includes an `Allow` header listing permitted methods (no body). -- **500 Internal Server Error** — uncaught exception from a handler. No body by default; override `ExceptionHandler` if you need a different envelope. +- **405 Method Not Allowed** — path matches but the HTTP method isn't declared. Includes an + `Allow` header listing permitted methods (no body). +- **500 Internal Server Error** — uncaught exception from a handler. No body by default; + override `ExceptionHandler` if you need a different envelope. -The error mapping is performed by `Handlers.defaultExceptionHandler()`. Pass your own `ExceptionHandler` to `OpenApiServer.builder().exceptionHandler(...)` if you need a different response shape (e.g. multi-error collection, custom problem types, locale-aware `detail`). +The error mapping is performed by `Handlers.defaultExceptionHandler()`. Pass your own +`ExceptionHandler` to `OpenApiServer.builder().exceptionHandler(...)` if you need a different +response shape (e.g. multi-error collection, custom problem types, locale-aware `detail`). -### Extra (non-OpenAPI) handlers +## Extra (non-OpenAPI) handlers -Mount handlers at arbitrary paths outside the OpenAPI spec — useful for liveness probes, -serving the spec document itself, or any other operational endpoint that should not be subject -to OpenAPI parameter / body validation. +Mount handlers at arbitrary paths outside the OpenAPI spec — useful for liveness probes, serving +the spec document itself, or any other operational endpoint that should not be subject to +OpenAPI parameter / body validation. ``` java var server = OpenApiServer.builder() @@ -694,81 +781,124 @@ var server = OpenApiServer.builder() ``` Extra handlers bypass OpenAPI validation but are still wrapped in the configured -`ExceptionHandler`, so any uncaught exception is rendered using the same error envelope as -API routes. +`ExceptionHandler`, so any uncaught exception is rendered using the same error envelope as API +routes. Built-in helpers: + - `Handlers.aliveHandler()` — 204 No Content on `GET`/`HEAD`, 405 otherwise. -- `Handlers.specHandler(classpathResource)` — serves a classpath resource (content-type - inferred from extension). Throws `IllegalArgumentException` at construction if the resource - is missing. +- `Handlers.specHandler(classpathResource)` — serves a classpath resource (content-type inferred + from extension). Throws `IllegalArgumentException` at construction if the resource is missing. -The original public constructors remain available for back-compat. +## End-to-end example -### Graceful shutdown +Gson on the classpath for request/response JSON, SnakeYAML on the classpath for the spec, one +interceptor binding a request-scoped tenant + correlation id, one decorator stamping the +correlation id on every response, one handler. No extra wiring. -`OpenApiServer` exposes `stop(int delaySeconds)` for explicit shutdown that waits up to the -given number of seconds for in-flight exchanges to complete before closing them. `0` stops -immediately. The same drain timeout can be wired into `close()` (and therefore -try-with-resources) via the builder: +``` java +package com.example.promotions; -```java -try (var server = OpenApiServer.builder() - .spec(spec) - .handlers(handlers) - .shutdownTimeoutSeconds(5) // close() drains up to 5s; default is 0 - .build()) { - // serve requests... -} // close() now waits up to 5s for in-flight exchanges -``` +import com.retailsvc.http.OpenApiServer; +import com.retailsvc.http.Request; +import com.retailsvc.http.RequestHandler; +import com.retailsvc.http.Response; +import com.retailsvc.http.spec.Spec; +import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; -`stop(int)` and `shutdownTimeoutSeconds(int)` reject negative values with -`IllegalArgumentException`. +public final class App { -## Features -- OpenAPI specification support -- Automatic request body parsing and response writing per media type via `TypeMapper` -- `RequestHandler` functional interface — a single `handle(Request)` method replaces raw `HttpExchange` manipulation -- Handlers are pure functions: `Response handle(Request)`. Factories cover `empty()` / `status(int)` / `ok(Object)` / `of(int, Object)` / `text(int, String)` / `bytes(int, byte[], String)` / `stream(...)` -- Built-in `GsonJsonMapper` auto-registered when Gson is on the classpath (no explicit wiring needed) -- `ResponseDecorator` for cross-cutting response headers and `RequestInterceptor` for around-style ScopedValue / MDC / auth concerns -- Built on Java's native `HttpServer` with Thread-Per-Request behaviour using Virtual Threads + static final ScopedValue TENANT = ScopedValue.newInstance(); + static final ScopedValue CORRELATION_ID = ScopedValue.newInstance(); + public static void main(String[] args) throws Exception { + Spec spec = Spec.fromPath(Path.of("openapi.yaml")); // SnakeYAML parses the spec -## Handler Registration -Handlers are registered in a `Map` keyed by OpenAPI `operationId`. + RequestHandler getPromotion = req -> { + String id = req.pathParam("id"); + return PromotionService.find(TENANT.get(), id) // uses bound tenant + .map(Response::ok) // 200 + JSON via Gson + .orElseGet(Response::notFound); // 404, no body + }; -## Local development + OpenApiServer.builder() + .spec(spec) + .handlers(Map.of("get-promotion", getPromotion)) + // Bind tenant + correlation id once per request. + .interceptor((req, next) -> { + String tenant = req.header("X-Tenant-Id").orElse("public"); + String correlationId = + req.header("X-Correlation-Id").orElseGet(() -> UUID.randomUUID().toString()); + return ScopedValue.where(TENANT, tenant) + .where(CORRELATION_ID, correlationId) + .call(next::proceed); + }) + // Stamp the correlation id on every response. + .responseDecorator((req, resp) -> resp.withHeader("X-Correlation-Id", CORRELATION_ID.get())) + .port(8080) + .build(); + } +} +``` -To test the server in isolation, you can start an example server (`src/test/java/com/retailsvc/http/start/ServerLauncher.java`). -Schemas are located under test resources folder. +What the example demonstrates: -- Example requests can be found under `acceptance/k6` that can be a base for exploring the functionality. -- The logger in the configuration needs to be enabled to get some insight into the code. +- **Gson is the default JSON serializer.** No explicit `bodyMapper(...)` call — the library + auto-registers `GsonJsonMapper` for request and response JSON because Gson is on the classpath. +- **SnakeYAML parses the spec.** `Spec.fromPath(...)` picks the parser by file extension; `.yaml` + here means SnakeYAML, and Gson would handle `.json` the same way. +- **One interceptor sets cross-cutting context.** `ScopedValue.where(...).call(next::proceed)` + runs the handler (and any inner interceptors and decorators) inside the binding, so + `TENANT.get()` and `CORRELATION_ID.get()` work anywhere they're called. +- **One decorator stamps a response header.** `Response.withHeader(...)` is non-destructive — + the handler's `Response` is replaced with one that has the extra header. +- **Handler is a pure function.** Reads from `Request`, returns a `Response` value. No + `HttpExchange`, no try/catch IOException, no builder. -## Caveats +## Local development -- **Single-process model.** No horizontal scaling primitives are bundled; run multiple instances behind a load balancer for production scale. -- **JDK `HttpServer` is the throughput ceiling.** It's documented as a low-throughput / dev-test server. If you need to go materially above the rates shown under [Performance](#performance), the handler-facing API (`Request`, `Response`, `RequestHandler`, `RequestInterceptor`, `ResponseDecorator`, `TypeMapper`) is transport-neutral by design — `Request` is built from primitives (body bytes, raw query string, path parameters, a header lookup function), not a JDK `HttpExchange`. A future enhancement could plug in a higher-throughput backend (Jetty, Helidon Níma, Netty) by writing a new adapter behind `com.retailsvc.http.internal` while leaving handlers untouched. -- **Per-request state uses `ScopedValue`** (Java 25, JEP 506). This matters if a handler offloads work to an executor that's not a `StructuredTaskScope`-managed child thread: the `ScopedValue` is not visible there, so the handler must capture the values it needs (e.g. `byte[] body = request.bytes();`) before submitting. -- **Empty responses use `Response.empty()` (204) or `Response.status(code)` for other no-body statuses.** The renderer sends `responseLength = -1` (`Content-Length: 0`, no body) for any `Response` with `body() == null`, regardless of status code. Passing `0` to the JDK directly produces a chunked response with zero chunks, which is technically non-conformant — `Response` factories handle this for you. +To test the server in isolation, you can start an example server +(`src/test/java/com/retailsvc/http/start/ServerLauncher.java`). Schemas are located under the +test resources folder. + +- Example requests can be found under `acceptance/k6` and can be a base for exploring the + functionality. +- The logger in the configuration needs to be enabled to get some insight into the code. ## Performance -The chart below shows sustained throughput and 95th-percentile latency of `openapi-httpserver-java` under a mixed-CRUD load (50 concurrent virtual users driven by k6 for 75 s after a 20 s warmup). The bench handlers do the minimum: parse the request via the registered `TypeMapper`, hit an in-memory store, and return a `Response`. There are no synthetic sleeps, no downstream calls, and no database — what you see is the framework path itself: routing, OpenAPI validation, JSON (de)serialisation, response rendering. +The chart below shows sustained throughput and 95th-percentile latency of +`openapi-httpserver-java` under a mixed-CRUD load (50 concurrent virtual users driven by k6 for +75 s after a 20 s warmup). The bench handlers do the minimum: parse the request via the +registered `TypeMapper`, hit an in-memory store, and return a `Response`. There are no synthetic +sleeps, no downstream calls, and no database — what you see is the framework path itself: +routing, OpenAPI validation, JSON (de)serialisation, response rendering. -Two profiles, both inside a CPU- and memory-capped Docker container running Temurin 25 on an Apple M1 Max: +Two profiles, both inside a CPU- and memory-capped Docker container running Temurin 25 on an +Apple M1 Max: -- **2 CPU / 1 GB** — the default profile. The framework sustains over 10,000 req/s with a p95 under 7 ms. -- **1 CPU / 512 MB** — the constrained profile. Throughput halves with CPU (the framework is CPU-bound, not lock- or IO-bound), and tighter memory pressures G1 into more old-generation collections, widening p95 to ~24 ms. The median request still completes in ~4 ms. +- **2 CPU / 1 GB** — the default profile. The framework sustains over 10,000 req/s with a p95 + under 7 ms. +- **1 CPU / 512 MB** — the constrained profile. Throughput halves with CPU (the framework is + CPU-bound, not lock- or IO-bound), and tighter memory pressures G1 into more old-generation + collections, widening p95 to ~24 ms. The median request still completes in ~4 ms. ![Performance: openapi-httpserver-java 1.0.3 throughput and p95 latency across two CPU/memory profiles](docs/perf-1.0.3.svg) ### How does that compare? -This is not a competition — different runtimes, different ecosystems, different sweet spots. It's a sanity check: where does `openapi-httpserver-java` land against a familiar reference point on the same hardware, under the same load? +This is not a competition — different runtimes, different ecosystems, different sweet spots. +It's a sanity check: where does `openapi-httpserver-java` land against a familiar reference +point on the same hardware, under the same load? -The reference point is a deliberately minimal Node.js service: Express 4 with `express-openapi-validator` against the same OpenAPI spec, handlers stripped to the same "parse, touch in-memory store, respond" shape, no synthetic sleeps. Both run inside the same 1 CPU / 512 MB Docker container; k6 drives the same mixed-CRUD workload at 50 VUs for 5 minutes of sustained measurement. +The reference point is a deliberately minimal Node.js service: Express 4 with +`express-openapi-validator` against the same OpenAPI spec, handlers stripped to the same "parse, +touch in-memory store, respond" shape, no synthetic sleeps. Both run inside the same 1 CPU / +512 MB Docker container; k6 drives the same mixed-CRUD workload at 50 VUs for 5 minutes of +sustained measurement. | Metric (1 CPU / 512 MB) | openapi-httpserver-java | Node + Express | |---|---|---| @@ -781,9 +911,32 @@ The reference point is a deliberately minimal Node.js service: Express 4 with `e A few things worth keeping in mind when reading this: -- **Both stacks held up for the full 5 minutes** with stable tails — nothing pathological on either side. -- **The Java advantage is mostly the JIT and the JVM thread pool.** Once hot, the framework dispatches requests through compiled code on real OS threads; Node serialises everything through a single event loop and pays for per-request JS validation in `express-openapi-validator`. -- **It is not a 10× story.** At 1 vCPU both runtimes are CPU-bound on essentially the same task. Expect roughly 2× throughput and ~2× tighter tail latency, not a runaway. -- The Node service used here is intentionally minimal; a tuned Fastify + AJV setup would close some of the gap, and a Go or Rust service would likely open it again in the opposite direction. The point of the comparison is to give you a feel for the ballpark, not to crown a winner. +- **Both stacks held up for the full 5 minutes** with stable tails — nothing pathological on + either side. +- **The Java advantage is mostly the JIT and the JVM thread pool.** Once hot, the framework + dispatches requests through compiled code on real OS threads; Node serialises everything + through a single event loop and pays for per-request JS validation in + `express-openapi-validator`. +- **It is not a 10× story.** At 1 vCPU both runtimes are CPU-bound on essentially the same task. + Expect roughly 2× throughput and ~2× tighter tail latency, not a runaway. +- The Node service used here is intentionally minimal; a tuned Fastify + AJV setup would close + some of the gap, and a Go or Rust service would likely open it again in the opposite + direction. The point of the comparison is to give you a feel for the ballpark, not to crown a + winner. + +## Caveats -## Known limitations or missing features +- **Single-process model.** No horizontal scaling primitives are bundled; run multiple instances + behind a load balancer for production scale. +- **JDK `HttpServer` is the throughput ceiling.** It's documented as a low-throughput / dev-test + server. If you need to go materially above the rates shown under [Performance](#performance), + the handler-facing API (`Request`, `Response`, `RequestHandler`, `RequestInterceptor`, + `ResponseDecorator`, `TypeMapper`) is transport-neutral by design — `Request` is built from + primitives (body bytes, raw query string, path parameters, a header lookup function), not a + JDK `HttpExchange`. A future enhancement could plug in a higher-throughput backend (Jetty, + Helidon Níma, Netty) by writing a new adapter behind `com.retailsvc.http.internal` while + leaving handlers untouched. +- **Per-request state uses `ScopedValue`** (Java 25, JEP 506). This matters if a handler + offloads work to an executor that's not a `StructuredTaskScope`-managed child thread: the + `ScopedValue` is not visible there, so the handler must capture the values it needs (e.g. + `byte[] body = request.bytes();`) before submitting.