Skip to content

extenda/openapi-httpserver-java

Repository files navigation

openapi-httpserver-java

Quality Gate Status Coverage Code Smells Duplicated Lines (%) WorkFlow OWASP ASVS

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.

Table of contents

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 9457 application/problem+json validation errors with an errors[] array of JSON-Pointers to the failing locations
  • Built on the JDK's native HttpServer with thread-per-request behaviour using virtual threads

Maven artifact

<dependency>
  <groupId>com.retailsvc</groupId>
  <artifactId>openapi-httpserver-java</artifactId>
  <version>${openapi-httpserver-java.version}</version>
</dependency>

Quick start

Prerequisites

  • 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.json or openapi.yaml).
  • A TypeMapper for 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.

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.

// 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).

2. Start the server

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();
  }
}

Multiple specs

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 loading

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>).

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:

  • 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:

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.

Body parsers and response writers

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. Produces Map<String, Object>. 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 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.

Server configuration

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.

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:

import java.net.InetAddress;

OpenApiServer.builder()
    .spec(spec)
    .handlers(handlers)
    .port(8080)
    .bindAddress(InetAddress.getLoopbackAddress())
    .build();

HTTPS

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 emptyDir shared 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 OpenApiServer instance

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:

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(...).

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();

Built-in: browser security headers

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.

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).

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.

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.

// 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);
  }
}

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.

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.

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 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 (in header, query, or cookie)
  • 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.

Declaring schemes in the spec

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: []

Registering validators

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);
})

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<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> userId
String 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.

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.

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.

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:

  • 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:

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.

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):

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.

Error responses (RFC 9457)

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 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).

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.

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 on GET/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). Throws IllegalArgumentException at construction if the resource or file is missing.
  • Cors.preflightHandler(...) — answers CORS OPTIONS preflight requests against caller-supplied allowlists. See CORS preflight below.

Wildcards in extra routes

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.

Health endpoint

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 preflight

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.

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.

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-registers GsonJsonMapper for request and response JSON because Gson is on the classpath.
  • SnakeYAML parses the spec. Spec.fromClasspath(...) 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.

Local development

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.

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.

Performance: openapi-httpserver-java 1.0.3 throughput and p95 latency across two CPU/memory profiles

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?

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

Java vs Node performance comparison: throughput and p95 latency at 1 CPU / 512 MB

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.

Caveats

  • 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, 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.

About

A dependency-reduced implementation of Javas HttpServer with OpenAPI validation

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages