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 9457 error rendering.
- Highlights
- Maven artifact
- Quick start
- Spec loading
- JSON mapping
- Body parsers and response writers
- Server configuration
- Interceptors and response decorators
- After-response hooks
- Security
- Request body content types
- Error responses (RFC 9457)
- Extra (non-OpenAPI) handlers
- Graceful shutdown
- End-to-end example
- Local development
- Performance
- Caveats
- OpenAPI 3.1.x driven routing, parameter validation, and request-body validation
- Pure-function handlers:
Response handle(Request)— noHttpExchangeplumbing - Per-media-type
TypeMappercontract for request parsing and response writing - Built-in
GsonJsonMapperauto-registered when Gson is on the classpath; Jackson 2.x and 3.x adapters available RequestInterceptorfor around-style concerns (ScopedValue, MDC, auth, tracing); composableResponseDecoratorfor cross-cutting response headers- OpenAPI
securitySchemesandsecurityenforcement (apiKey,http bearer,http basic), with an opt-out for sidecar / gateway authentication - RFC 9457
application/problem+jsonvalidation errors with anerrors[]array of JSON-Pointers to the failing locations - Built on the JDK's native
HttpServerwith thread-per-request behaviour using virtual threads
<dependency>
<groupId>com.retailsvc</groupId>
<artifactId>openapi-httpserver-java</artifactId>
<version>${openapi-httpserver-java.version}</version>
</dependency>- Java SDK 25 or later.
- A JSON or YAML library to parse the spec into a
Map<String, Object>— Gson, Jackson, or SnakeYAML are all supported. The library itself doesn't bundle one. - An OpenAPI 3.1.x specification (
openapi.jsonoropenapi.yaml). - A
TypeMapperfor any media type your operations use. See JSON mapping and Body parsers and response writers for the built-ins and how to register your own.
Handlers implement the RequestHandler functional interface. They consume a Request and return
a Response; the framework renders status code, headers, and body for you.
// Inline lambda — returns JSON using the built-in Gson mapper.
RequestHandler getDataHandler = req -> Response.ok(Map.of("id", "some-id"));
// Class form — reads raw bytes, the loose Map view, or a typed POJO.
public class PostDataHandler implements RequestHandler {
@Override
public Response handle(Request request) {
// Access the raw request body bytes.
byte[] body = request.bytes();
// Loose structural view (Map / List / boxed primitives), produced by the registered TypeMapper.
Object parsed = request.parsed();
// Or get a typed POJO directly (works with the Gson and Jackson built-ins; both implement
// TypedTypeMapper).
MyDto dto = request.asPojo(MyDto.class);
// Path parameters, query parameters, and headers are also available.
String id = request.pathParam("id"); // null if absent
Optional<String> filter = request.queryParam("filter"); // empty if absent or blank
Optional<String> corr = request.header("correlation-id");
return Response.ok(dto);
}
}Response is an immutable record built via static factories. Pick the one that fits:
Response.empty(); // 204 No Content, no body
Response.status(200); // 200 OK, no body
Response.ok(Map.of("id", "42")); // 200 OK, JSON body via TypeMapper
Response.created(newResource); // 201 Created, JSON body
Response.created(newResource)
.withHeader("Location", "/things/42"); // 201 Created + Location header
Response.accepted(); // 202 Accepted, no body
Response.accepted(Map.of("jobId", "job-42")); // 202 Accepted, JSON body
Response.notFound(); // 404 Not Found, no body
Response.notFound(problemDetail); // 404 Not Found, JSON body
Response.notImplemented(); // 501 Not Implemented, no body
Response.of(409, conflictDetail); // any status, JSON body
Response.text(200, "hello"); // text/plain; UTF-8
Response.bytes(200, pdf, "application/pdf"); // pre-serialised bytes
Response.stream(200, "application/octet-stream", // chunked streaming
out -> out.write(largeBlob));
Response.stream(200, length, "application/pdf", // sized streaming
out -> pipeFromBackend(out));Add or modify pieces non-destructively:
return Response.ok(payload)
.withHeader("X-Tenant-Id", tenant)
.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).
Handlers are registered in a Map<String, RequestHandler> keyed by OpenAPI operationId.
public class YourServerLauncher {
public static void main(String[] args) throws Exception {
// openapi.json lives in src/main/resources/, so it ships at the JAR root.
Spec spec = Spec.fromClasspath(YourServerLauncher.class, "/openapi.json");
Map<String, RequestHandler> handlers = new HashMap<>();
handlers.put("get-data", getDataHandler);
handlers.put("post-data", new PostDataHandler());
var server = OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.exceptionHandler(Handlers.defaultExceptionHandler())
.build();
}
}A single server instance can host more than one OpenAPI spec — useful for running a v1/v2 API
side-by-side, or a public and an internal admin surface in the same process. Each addSpec() call
registers one spec and its handlers as an independent binding. The JDK HttpServer routes
incoming requests to the binding whose basePath (the path component of servers[0].url) best
matches the request URI.
operationIds and security-scheme names only need to be unique within a single spec — two
specs can each declare a getCustomer operation without conflict.
OpenApiServer server = OpenApiServer.builder()
.port(8080)
.addSpec(v1Spec, Map.of(
"getCustomer", new V1GetCustomer(),
"listCustomers", new V1ListCustomers()))
.addSpec(v2Spec, Map.of(
"getCustomer", new V2GetCustomer(),
"listCustomers", new V2ListCustomers(),
"createCustomer", new V2CreateCustomer()))
.build();Each spec that declares securitySchemes gets its own validator map via the three-argument
overload:
OpenApiServer.builder()
.port(8080)
.addSpec(v1Spec, v1Handlers, Map.of(
"bearerAuth", (req, cred) -> jwt.verify(((BearerCredential) cred).token())))
.addSpec(v2Spec, v2Handlers, Map.of(
"apiKeyAuth", (req, cred) -> apiKeyStore.lookup(((ApiKeyCredential) cred).value())))
.build();Mixing addSpec() with the legacy spec()/handlers()/securityValidator() methods in the
same builder call is rejected at build time with IllegalStateException.
Spec.fromClasspath(Class<?>, String) is the recommended way to load a spec packaged with your
application. It 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 resource yourself
and use Spec.from(Map<String, Object>) instead. Any other extension is rejected.
// Spec at src/main/resources/openapi.json → JAR root → absolute path.
Spec spec = Spec.fromClasspath(YourServerLauncher.class, "/openapi.json");Mind the leading slash. fromClasspath resolves the resource via
Class.getResourceAsStream, which is package-relative unless the name starts with /. So
"/openapi.yaml" means "JAR root" (typical for src/main/resources/openapi.yaml), while
"openapi.yaml" means "next to YourServerLauncher.class" — i.e. the file must live under
src/main/resources/<your/package>/openapi.yaml. Easy to miss; if you get
IllegalArgumentException: classpath resource not found, the slash is the first thing to check.
If you already have the bytes or are loading from somewhere other than the classpath, the
InputStream overloads work too — both close the stream before returning:
Spec spec;
try (InputStream in = Files.newInputStream(Path.of("openapi.json"))) {
spec = Spec.fromJson(in); // or Spec.fromYaml(in)
}If you can't (or don't want to) depend on Gson, supply your own JSON parser:
ObjectMapper jackson = new ObjectMapper();
Spec spec;
try (InputStream in = YourServerLauncher.class.getResourceAsStream("/openapi.json")) {
spec = Spec.fromJson(in, bytes -> jackson.readValue(bytes, Map.class));
}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<String, Object>).
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
Longand fractional numbers asDoublefor the looserequest.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:
var server = OpenApiServer.builder()
.spec(spec)
.jsonMapper(new GsonTypeMapper(myGson))
.handlers(handlers)
.build();To extend the library default (instead of building a Gson from scratch), unwrap it via
gsonBuilder():
Gson custom =
new GsonTypeMapper()
.gsonBuilder()
.registerTypeAdapter(Money.class, new MoneyAdapter())
.create();
var server = OpenApiServer.builder()
.spec(spec)
.jsonMapper(new GsonTypeMapper(custom))
.handlers(handlers)
.build();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:
// Jackson 2.x (group: com.fasterxml.jackson.core)
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
ObjectMapper objectMapper = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
var server = OpenApiServer.builder()
.spec(spec)
.jsonMapper(new Jackson2JsonTypeMapper(objectMapper))
.handlers(handlers)
.build();// Jackson 3.x (group: tools.jackson.core)
import tools.jackson.databind.ObjectMapper;
ObjectMapper objectMapper = ObjectMapper.builder()
// ... configure modules, features, etc.
.build();
var server = OpenApiServer.builder()
.spec(spec)
.jsonMapper(new Jackson3JsonTypeMapper(objectMapper))
.handlers(handlers)
.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.
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.
TypeMapper is the per-media-type read/write contract:
public interface TypeMapper {
Object readFrom(byte[] body, String contentTypeHeader);
byte[] writeTo(Object value);
}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. ProducesMap<String, Object>. A single value is aString; repeated keys produce aList.text/plain— read and write. Produces a decodedString; writes viaString.getBytes().application/json— auto-registered when Gson is on the classpath (see 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.
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.
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:
import java.net.InetAddress;
OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.port(8080)
.bindAddress(InetAddress.getLoopbackAddress())
.build();Point the builder at a PEM certificate chain and a PEM PKCS#8 private key:
import java.nio.file.Path;
var server = OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.https(
Path.of("/etc/letsencrypt/live/example.com/fullchain.pem"),
Path.of("/etc/letsencrypt/live/example.com/privkey.pem"))
.build();certbot / Let's Encrypt write exactly these two files to
/etc/letsencrypt/live/<domain>/: fullchain.pem (your certificate + the
issuing intermediates, concatenated PEM) and privkey.pem (unencrypted PKCS#8).
No conversion to PKCS12 / JKS is needed; the library parses the PEM directly
using JDK APIs only.
Both RSA and EC (P-256) private keys are accepted; the algorithm is detected automatically.
Deployment. Don't bake privkey.pem into your container image — you
lose rotation and leak the key into image layers and registries. Mount the
two PEM files at runtime from a secret manager:
- Kubernetes: cert-manager writes the
certificate and key into a
Secret; mount it as a volume at the path you pass to.https(...). Renewal is automatic; restart the pod (e.g. via a rolling deploy keyed off the Secret's revision) to pick up the new cert. - GCP: Store both files in Secret Manager and project them with the
Secret Manager CSI driver
or a Workload Identity-bound init container that writes the files to an
emptyDirshared with the app container. - AWS: Secrets Manager via the AWS Secrets and Configuration Provider for the CSI driver follows the same pattern.
Whatever the source: mount the volume read-only, give privkey.pem mode
0400 (owner-read only), and ensure the JVM process owns or can read it.
When .https(...) is set, the default port changes from 8080 to 8443.
port(int) still overrides explicitly:
OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.https(certChain, privateKey)
.port(443) // overrides the 8443 default
.build();For local development without a real certificate, generate a self-signed pair with one openssl command:
openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
-keyout privkey.pem -out fullchain.pem \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"Clients (browsers, curl, HttpClient) need to trust the resulting certificate
explicitly — it isn't signed by a public CA.
Not in this release (each can land later without breaking the API):
- Encrypted / password-protected private keys
- PKCS12 / JKS keystore inputs
- Certificate hot-reload on renewal (restart the process after
certbot renew) - TLS protocol / cipher overrides (JDK defaults apply: TLS 1.2 and 1.3)
- Serving HTTP and HTTPS from one
OpenApiServerinstance
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:
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 exchangesstop(int) and shutdownTimeoutSeconds(int) reject negative values with
IllegalArgumentException.
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(...).
OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.responseDecorator((req, resp) -> resp.withHeader("X-Correlation-Id", CorrelationId.current()))
.responseDecorator((req, resp) -> resp.withHeader("X-Tenant-Id", TenantId.current()))
.build();Handlers.securityHeadersDecorator() adds two browser-hardening headers to every response —
X-Content-Type-Options: nosniff and Cross-Origin-Resource-Policy: same-origin. Handler-supplied
values for either header are preserved, so individual responses can opt out by setting the header
explicitly.
OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.responseDecorator(Handlers.securityHeadersDecorator())
.build();Decorators run on the dispatch path only — error responses produced by ExceptionFilter (e.g.
the default 500) bypass them.
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).
OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.interceptor((request, next) -> {
// Resolve once per request; bind to a ScopedValue for the rest of the chain.
String tenant = request.header("X-Tenant-Id").orElse("public");
return ScopedValue.where(TENANT, tenant).call(next::proceed);
})
.interceptor((request, next) -> {
MDC.put("op", request.operationId());
try {
return next.proceed();
} finally {
MDC.remove("op");
}
})
.build();Exceptions propagate to the library's standard ExceptionFilter and ExceptionHandler pipeline.
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.
// Per-request context populated by the interceptor, read by the decorator and handlers.
ScopedValue<String> CORRELATION_ID = ScopedValue.newInstance();
ScopedValue<String> TENANT_ID = ScopedValue.newInstance();
OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
// 1. Resolve once per request and bind to ScopedValues.
.interceptor((request, next) -> {
String correlationId =
request.header("X-Correlation-Id").orElseGet(() -> UUID.randomUUID().toString());
String tenantId = resolveTenant(request);
return ScopedValue.where(CORRELATION_ID, correlationId)
.where(TENANT_ID, tenantId)
.call(next::proceed);
})
// 2. Stamp those values on every response.
.responseDecorator((req, resp) -> resp
.withHeader("X-Correlation-Id", CORRELATION_ID.get())
.withHeader("X-Tenant-Id", TENANT_ID.get()))
.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. If a decorator throws, the
exception propagates through any wrapping interceptors before reaching the ExceptionFilter, so
interceptors that catch around next.proceed() will observe decorator failures.
A handler in this setup is just business logic:
public class GetPromotionHandler implements RequestHandler {
@Override
public Response handle(Request request) {
String id = request.pathParam("id");
String tenant = TENANT_ID.get();
return promotionService
.find(tenant, id)
.<Response>map(Response::ok)
.orElseGet(Response::notFound);
}
}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.
OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.afterResponseHook((req, resp) ->
metrics.timer("http.request").record(req.operationId(), resp.status()))
.build();Handlers can also queue per-request runnables:
Map<String, RequestHandler> handlers = Map.of(
"getThings", req -> {
req.afterResponse(() -> auditLog.flush());
return Response.ok(things);
});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.
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 9457 application/problem+json rejections — 401 for
missing/malformed credentials (with WWW-Authenticate), 403 when the validator denies.
Supported scheme types in this release:
apiKey(inheader,query, orcookie)httpbearerhttpbasic
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.
components:
securitySchemes:
apiKeyAuth:
type: apiKey
name: X-API-Key
in: header
bearerAuth:
type: http
scheme: bearer
basicAuth:
type: http
scheme: basic
# Either default for every operation:
security:
- bearerAuth: []
# Or attach per-operation (overrides the root default):
paths:
/reports/{id}:
get:
operationId: getReport
security:
- apiKeyAuth: []
responses:
"200": { description: ok }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:
security:
# Either an API key …
- apiKeyAuth: []
# … or BOTH a bearer token AND a tenant header validator:
- bearerAuth: []
tenantAuth: []import com.retailsvc.http.Credential;
import com.retailsvc.http.Credential.ApiKeyCredential;
import com.retailsvc.http.Credential.BearerCredential;
import com.retailsvc.http.Credential.BasicCredential;
import com.retailsvc.http.OpenApiServer;
import java.util.Optional;
OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.securityValidator("apiKeyAuth", (request, credential) -> {
String key = ((ApiKeyCredential) credential).value();
return apiKeyStore.lookup(key).map(user -> user); // Optional<User>
})
.securityValidator("bearerAuth", (request, credential) -> {
String token = ((BearerCredential) credential).token();
return jwt.verify(token).map(claims -> claims); // Optional<JwtClaims>
})
.securityValidator("basicAuth", (request, credential) -> {
BasicCredential bc = (BasicCredential) credential;
return userService
.authenticate(bc.username(), bc.password())
.map(user -> user); // Optional<User>
})
.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:
.securityValidator("multi", (request, credential) -> switch (credential) {
case ApiKeyCredential ak -> apiKeyStore.lookup(ak.value()).map(user -> user);
case BearerCredential b -> jwt.verify(b.token()).map(claims -> claims);
case BasicCredential bc -> userService.authenticate(bc.username(), bc.password()).map(u -> u);
})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<Object> and the library stashes the value on the Request under the scheme name.
Whatever you return becomes your principal.
Three common patterns:
1. A domain record. Best for typed access in handlers.
public record AuthenticatedUser(String userId, String tenantId, Set<String> roles) {}
.securityValidator("bearerAuth", (request, credential) -> {
String token = ((BearerCredential) credential).token();
return jwt.verify(token).map(claims ->
new AuthenticatedUser(claims.subject(), claims.tenant(), claims.roles()));
})Handler reads it:
public Response handle(Request request) {
AuthenticatedUser user = (AuthenticatedUser) request.principal("bearerAuth").orElseThrow();
return Response.ok(reports.findForTenant(user.tenantId()));
}2. A Map<String, Object> of claims. Useful when the shape is dynamic or you want to forward
JWT claims as-is.
.securityValidator("bearerAuth", (request, credential) ->
jwt.verify(((BearerCredential) credential).token()).map(claims -> Map.copyOf(claims.asMap())))@SuppressWarnings("unchecked")
Map<String, Object> claims = (Map<String, Object>) request.principal("bearerAuth").orElseThrow();
String sub = (String) claims.get("sub");3. A plain String identifier. Simplest when the handler only needs an ID.
.securityValidator("apiKeyAuth", (request, credential) ->
apiKeyStore.lookup(((ApiKeyCredential) credential).value())) // Optional<String> userIdString userId = (String) request.principal("apiKeyAuth").orElseThrow();If your operation requires multiple schemes simultaneously (AND-group), all principals are stashed under their scheme names:
Map<String, Object> 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.
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.
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:
OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.useExternalAuthentication() // SecurityFilter becomes a no-op
.build();Effects when set:
SecurityFiltershort-circuits to the next chain step regardless of anysecuritydeclarations — 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)returnsOptional.empty(). The library never reads sidecar-set headers. If you want a principal in the handler, write a normalRequestInterceptorthat reads whatever header the sidecar sets and binds aScopedValue(or stashes on the request via a domain wrapper of your own).
Typical sidecar pattern:
ScopedValue<String> AUTHENTICATED_USER = ScopedValue.newInstance();
OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.useExternalAuthentication()
.interceptor((request, next) -> {
String user = request.header("X-Authenticated-User").orElseThrow();
return ScopedValue.where(AUTHENTICATED_USER, user).call(next::proceed);
})
.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 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 |
|---|---|---|
application/json |
GsonJsonMapper (auto) or caller-supplied TypeMapper |
No — strict against the schema |
application/x-www-form-urlencoded |
Built-in. Map<String, Object>. 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<Long>). |
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-9457 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.
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 9457 (which obsoletes RFC 7807).
The top level carries the RFC core members; each individual failure is an entry in an errors
array (an RFC 9457 extension member). A non-combinator failure yields a single entry; a
oneOf / anyOf failure yields one entry per failed branch, ordered most-likely-cause first
(the branch the payload most resembles) and de-duplicated.
| Field | Type | Description |
|---|---|---|
type |
string | Always about:blank (no per-error type URI). |
title |
string | Always Bad Request. |
status |
integer | Always 400. |
detail |
string | Human-readable description (a leaf message; for a combinator, matched 0 of N oneOf branches or did not match any anyOf branch). |
errors |
array | One entry per failure; omitted when empty. Each entry has the fields below. |
Each errors[] entry:
| Field | Type | Description |
|---|---|---|
pointer |
string | RFC 6901 JSON-Pointer to the failing location, as a URI fragment — e.g. #/age for a body field, #/query/limit / #/path/id for parameters, #/body for whole-body errors (missing body, unsupported content type), or # when the entire body is the wrong type. |
keyword |
string | The validation rule that failed: type, required, enum, pattern, format, minimum, maximum, minLength, maxLength, additionalProperties, oneOf, anyOf, allOf, not, const, content-type, decode, … |
detail |
string | Human-readable description of this failure (e.g. expected integer). |
Example body for POST /form-echo with age=abc (age is declared as integer):
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "expected integer",
"errors": [
{ "pointer": "#/age", "keyword": "type", "detail": "expected integer" }
]
}Example body for a oneOf request body that matches no branch — one entry per failed branch,
deepest (most-likely) first:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "matched 0 of 2 oneOf branches",
"errors": [
{ "pointer": "#/pet/collar/size", "keyword": "type", "detail": "expected integer" },
{ "pointer": "#/pet/bark", "keyword": "type", "detail": "expected boolean" }
]
}When several branches fail at the same location for the same reason, those identical entries are
collapsed into one — so a oneOf failure can show fewer entries than it has branches.
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
Allowheader listing permitted methods (no body). - 500 Internal Server Error — uncaught exception from a handler. No body by default;
override
ExceptionHandlerif 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).
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.
var server = OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.extraRoute("/alive", Handlers.aliveHandler())
.extraRoute("/schemas/v1/openapi.yaml",
Handlers.resourceHandler("/schemas/v1/openapi.yaml"))
.build();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.
Built-in helpers:
Handlers.aliveHandler()— 204 No Content onGET/HEAD, 405 otherwise.Handlers.healthHandler(Supplier<HealthOutcome>)— readiness probe that aggregates dependency statuses. See Health endpoint below.Handlers.resourceHandler(classpathResource)/Handlers.resourceHandler(Path)— streams a classpath resource or filesystem file (content-type inferred from extension; the stream is opened and closed per request, and the handler owns its lifecycle). ThrowsIllegalArgumentExceptionat construction if the resource or file is missing.Cors.preflightHandler(...)— answers CORSOPTIONSpreflight requests against caller-supplied allowlists. See CORS preflight below.
Extra routes accept two wildcard tokens (these are not part of OpenAPI; they apply only to extras, which are outside the spec):
*— matches exactly one path segment (no/).**— matches zero or more characters, may cross/boundaries.
Both must appear as whole segments (/files/*, /files/**,
/schemas/**/openapi.yaml). Mixed-segment patterns like prefix-*.json
are rejected at boot.
The matched portion is not exposed to the handler. If you map a wildcard
extra to a filesystem location, canonicalise via Path.toRealPath() and
assert resolved.startsWith(baseReal) to prevent escape — the router
blocks ., .., encoded %2e/%2f/%5c/%00, control characters and
malformed encoding with a 400, but cannot police what the handler does
with the matched path.
Handlers.healthHandler(probe) mounts a readiness endpoint that aggregates per-dependency
statuses into a single response. The probe is invoked on every request, so it sees current
backend state.
RequestHandler health = Handlers.healthHandler(() -> new HealthOutcome(List.of(
new Dependency("jdbc", dataSource.isReachable()),
new Dependency("kafka", kafkaClient.isConnected()))));
var server = OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.extraRoute("/health", health)
.build();Status code derives from the dependency list: 200 OK when every dependency is up (vacuously
true for an empty list), 503 Service Unavailable otherwise. The wire shape is the same in both
cases:
{
"outcome": "Up",
"dependencies": [
{"id": "jdbc", "status": "Up"},
{"id": "kafka", "status": "Up"}
]
}{
"outcome": "Down",
"dependencies": [
{"id": "jdbc", "status": "Up"},
{"id": "kafka", "status": "Down"}
]
}The body is rendered by a built-in writer — no JSON library on the classpath is required. A
probe that throws or returns null is logged at WARN and surfaces as a Down response with an
empty dependency list; the exception never reaches the configured ExceptionHandler. GET and
HEAD are accepted; other methods return 405 Method Not Allowed with an Allow: GET, HEAD
header.
Cors.preflightHandler(...) answers OPTIONS preflight requests so browsers can
perform cross-origin calls against the server. The handler is preflight-only — wire it on a
wildcard extraRoute covering the routes you want to expose to browsers.
var server = OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.extraRoute("/api/**", Cors.preflightHandler(
List.of("https://app.example.com"),
List.of(GET, POST, PUT, DELETE),
List.of("content-type", "authorization"),
true, // Access-Control-Allow-Credentials
Duration.ofMinutes(10))) // Access-Control-Max-Age
.build();For dynamic origin policy (regex match, suffix match, tenant lookup) pass a Predicate<String>
instead of a List<String>. Allowed-headers comparison is case-insensitive. Disallowed origins,
methods, or headers return 403 with no CORS headers (the browser then blocks the request);
non-OPTIONS requests return 405 with Allow: OPTIONS; preflights missing the Origin or
Access-Control-Request-Method header return 400.
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.
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;
public final class App {
static final ScopedValue<String> TENANT = ScopedValue.newInstance();
static final ScopedValue<String> CORRELATION_ID = ScopedValue.newInstance();
public static void main(String[] args) throws Exception {
Spec spec = Spec.fromClasspath(App.class, "/openapi.yaml"); // SnakeYAML parses the spec
RequestHandler getPromotion = req -> {
String id = req.pathParam("id");
return PromotionService.find(TENANT.get(), id) // uses bound tenant
.<Response>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-registersGsonJsonMapperfor request and response JSON because Gson is on the classpath. - SnakeYAML parses the spec.
Spec.fromClasspath(...)picks the parser by file extension;.yamlhere means SnakeYAML, and Gson would handle.jsonthe 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, soTENANT.get()andCORRELATION_ID.get()work anywhere they're called. - One decorator stamps a response header.
Response.withHeader(...)is non-destructive — the handler'sResponseis replaced with one that has the extra header. - Handler is a pure function. Reads from
Request, returns aResponsevalue. NoHttpExchange, no try/catch IOException, no builder.
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/k6and can be a base for exploring the functionality. - The logger in the configuration needs to be enabled to get some insight into the code.
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:
- 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.
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.
| Metric (1 CPU / 512 MB) | openapi-httpserver-java | Node + Express |
|---|---|---|
| Aggregate throughput | 10,680 req/s | 4,595 req/s |
| p50 latency | 3.5 ms | 8.7 ms |
| p95 latency | 12.8 ms | 24.0 ms |
| p99 latency | 24.7 ms | 35.4 ms |
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.
- Single-process model. No horizontal scaling primitives are bundled; run multiple instances behind a load balancer for production scale.
- JDK
HttpServeris 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, the handler-facing API (Request,Response,RequestHandler,RequestInterceptor,ResponseDecorator,TypeMapper) is transport-neutral by design —Requestis built from primitives (body bytes, raw query string, path parameters, a header lookup function), not a JDKHttpExchange. A future enhancement could plug in a higher-throughput backend (Jetty, Helidon Níma, Netty) by writing a new adapter behindcom.retailsvc.http.internalwhile 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 aStructuredTaskScope-managed child thread: theScopedValueis not visible there, so the handler must capture the values it needs (e.g.byte[] body = request.bytes();) before submitting.