A lightweight Java library for creating HTTP servers based on OpenAPI specifications.
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.
- Java SDK 25 or later.
- A JSON library to parse the spec into a
Map<String, Object>: any of Gson, Jackson, SnakeYAML (for YAML specs), or another mapper of your choice. The library itself doesn't bundle one. - An OpenAPI 3.1.x specification (
openapi.jsonoropenapi.yaml). - For
application/jsonrequest/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.*) orJackson3JsonTypeMapper(ObjectMapper)for Jackson 3.x (tools.jackson.*). Caller supplies a configuredObjectMapper; the two adapters use disjoint package roots and can coexist on the same classpath. - any other
TypeMapperyou register viaBuilder.jsonMapper(mapper)(shortcut forbodyMapper("application/json", mapper)).
- Gson on the classpath — auto-registered via the built-in
- Built-in mappers for
application/x-www-form-urlencodedandtext/plainneed no configuration. Any other media type (application/xml,application/cbor, etc.) requires registering its ownTypeMapper.
- Create an OpenAPI specification file named
openapi.jsonin your project resources. - Define your handlers using the
RequestHandlerfunctional interface. Handlers are pure functions: they consume aRequestand return aResponse. The framework renders the response (status code, headers, 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).
- Initialize the server:
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<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();
}
}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<String, Object>) instead. Any other extension is rejected.
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.
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:
// 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). 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 above).
User-supplied mappers take precedence over built-in defaults, so you can override any of the above.
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();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.
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);
}
}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.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
.<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.fromPath(...)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.
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-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.
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.
A single error is reported per request (first failure wins). The response body has these fields:
| 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 of the failure (e.g. expected integer). |
pointer |
string | RFC 6901 JSON-Pointer to the failing location (e.g. /body/age, /query/limit, /path/id, or /body for body-wide errors). |
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, … |
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",
"pointer": "/age",
"keyword": "type"
}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.specHandler("/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.specHandler(classpathResource)— serves a classpath resource (content-type inferred from extension). ThrowsIllegalArgumentExceptionat construction if the resource is missing.
The original public constructors remain available for back-compat.
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.
- OpenAPI specification support
- Automatic request body parsing and response writing per media type via
TypeMapper RequestHandlerfunctional interface — a singlehandle(Request)method replaces rawHttpExchangemanipulation- Handlers are pure functions:
Response handle(Request). Factories coverempty()/status(int)/ok(Object)/of(int, Object)/text(int, String)/bytes(int, byte[], String)/stream(...) - Built-in
GsonJsonMapperauto-registered when Gson is on the classpath (no explicit wiring needed) ResponseDecoratorfor cross-cutting response headers andRequestInterceptorfor around-style ScopedValue / MDC / auth concerns- Built on Java's native
HttpServerwith Thread-Per-Request behaviour using Virtual Threads
Handlers are registered in a Map<String, RequestHandler> keyed by OpenAPI operationId.
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.
- Example requests can be found under
acceptance/k6that 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 library wraps the JDK's bundled com.sun.net.httpserver.HttpServer and uses a virtual-thread-per-request executor. On a developer laptop (Apple Silicon, single instance, default JVM flags) it sustains roughly:
- ~32k requests/second for small JSON GETs and POSTs (~300 byte bodies), measured via
k6at 30 sustained VUs over 45 seconds (1.4M requests, 100% of checks passing, 0% HTTP failures).
A few things to know:
- 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 above, 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. - Empty responses use
Response.empty()(204) orResponse.status(code)for other no-body statuses. The renderer sendsresponseLength = -1(Content-Length: 0, no body) for anyResponsewithbody() == null, regardless of status code. Passing0to the JDK directly produces a chunked response with zero chunks, which is technically non-conformant —Responsefactories handle this for you.