Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v7

- name: Setup Java
uses: actions/setup-java@v6
with:
distribution: temurin
java-version-file: .java-version

- name: Run pre-commit
uses: pre-commit/actions@v3.0.1
with:
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ repos:
hooks:
- id: editorconfig-checker
- repo: https://github.com/extenda/pre-commit-hooks
rev: v0.15.0
rev: v0.16.1
hooks:
- id: google-java-formatter
- id: commitlint
Expand Down
19 changes: 11 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,23 @@ Java 25 is required (see `.java-version`). The server uses thread-per-request wi
Request flow when `OpenApiServer` boots (`src/main/java/com/retailsvc/http/OpenApiServer.java`):

1. `HttpServer` is created on a port with a virtual-thread-per-task executor.
2. A single `HttpContext` is registered at `spec.basePath()` (the first `servers[].url` path from the OpenAPI doc). A catch-all `/` context returns 404.
3. Three filters run in order on every request:
- `ExceptionFilter` — wraps the chain; delegates uncaught exceptions to the user-supplied `ExceptionHandler` (default in `Handlers`).
- `RequestPreparationFilter` — reads the raw request body, stashes it as an exchange attribute, runs OpenAPI parameter + body validation via `DefaultValidator`, and stores the resolved `operationId` on the exchange.
- `DispatchHandler` — looks up the `HttpHandler` registered for that `operationId` in the user-supplied map and invokes it. Handler coverage is verified at boot, so the lookup never returns `null`.
2. One `HttpContext` is registered per spec binding at `spec.basePath()` (the first `servers[].url` path from the OpenAPI doc). Unless a binding owns `/`, a catch-all `/` context serves extra routes via `ExtrasRouter` and 404s everything else; `ExceptionFilter` wraps that context only.
3. On a binding context, two filters run in order, then the handler:
- `RequestPreparationFilter` — reads the request body through `RequestBodyReader` (which decodes a registered `Content-Encoding` — gzip is built in — under a size cap), resolves the route, runs OpenAPI parameter + body validation via `DefaultValidator`, and binds the resulting `Request` into the `DispatchHandler.CURRENT` scoped value. It renders its own failures through the `ExceptionHandler` rather than relying on `ExceptionFilter`.
- `SecurityFilter` — enforces the spec's `securitySchemes` / `security`, re-binding the `Request` with resolved principals. It writes its 401/403 responses straight to the exchange.
- `DispatchHandler` — looks up the `RequestHandler` registered for the resolved `operationId` in the user-supplied map and invokes it, applying interceptors and response decorators. Handler coverage is verified at boot, so the lookup never returns `null`.

Every response except `SecurityFilter`'s rejections is written by `ResponseRenderer`, which is also where response content coding is applied.

Key abstractions:

- `com.retailsvc.http.spec.Spec` — parsed from a consumer-supplied `Map<String, Object>` via `Spec.from(raw)`. No JSON library dependency in the library itself; callers use Gson, Jackson, SnakeYAML, etc. to produce the map.
- Sealed `com.retailsvc.http.spec.schema.Schema` interface with per-kind records (`StringSchema`, `NumberSchema`, `IntegerSchema`, `ArraySchema`, `ObjectSchema`, `BooleanSchema`, `NullSchema`, `AnyOfSchema`, `AllOfSchema`, `OneOfSchema`). Pattern-match dispatch eliminates instanceof chains.
- `com.retailsvc.http.validate.DefaultValidator` — single class using `switch` pattern-match over `Schema` subtypes. Validation failures produce RFC 7807 `application/problem+json` 400 responses.
- `com.retailsvc.http.validate.DefaultValidator` — single class using `switch` pattern-match over `Schema` subtypes. Validation failures produce RFC 9457 `application/problem+json` 400 responses.
- `com.retailsvc.http.internal.Router` — two indexes: exact path map and templated path list. Resolves `operationId` + extracted path variables for each request.
- `JsonMapper` — `@FunctionalInterface`; single method `Object mapFrom(byte[])`. Callers supply a lambda (see README).
- `com.retailsvc.http.Request` — static helper; `Request.bytes(exchange)` returns raw body bytes, `Request.parsed(exchange)` returns the `Object` produced by the `JsonMapper`.
- `TypeMapper` — per-media-type request parsing and response writing; registered via `Builder.bodyMapper(...)`, with `GsonTypeMapper` auto-registered when Gson is on the classpath.
- `com.retailsvc.http.Request` — an immutable record-like carrier built from primitives (body bytes, path parameters, raw query string, a header lookup function), never the `HttpExchange`. `bytes()` returns the decoded body, `parsed()` the object produced by the `TypeMapper`.
- `com.retailsvc.http.ContentCoding` — a pluggable HTTP content coding. gzip is built in (`internal/GzipCoding`); callers register others on the builder, held per direction in `internal/ContentCodings`. `RequestBodyReader` decodes requests under the size cap and `ResponseRenderer` codes responses. See the README's "Content encoding" section for the policy.

## Conventions

Expand Down
86 changes: 86 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ endpoints declared in an OpenAPI 3.1.x specification. Handlers are pure function
- [Body parsers and response writers](#body-parsers-and-response-writers)
- [Server configuration](#server-configuration)
- [HTTPS](#https)
- [Content encoding](#content-encoding)
- [Interceptors and response decorators](#interceptors-and-response-decorators)
- [After-response hooks](#after-response-hooks)
- [Security](#security)
Expand All @@ -48,6 +49,8 @@ endpoints declared in an OpenAPI 3.1.x specification. Handlers are pure function
- 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
- Transparent gzip: request bodies are inflated under a zip-bomb ceiling, responses are compressed
when the client accepts it and the payload is worth it
- Built on the JDK's native `HttpServer` with thread-per-request behaviour using virtual threads

## Maven artifact
Expand Down Expand Up @@ -459,6 +462,83 @@ explicitly — it isn't signed by a public CA.
- TLS protocol / cipher overrides (JDK defaults apply: TLS 1.2 and 1.3)
- Serving HTTP and HTTPS from one `OpenApiServer` instance

### Content encoding

gzip is handled in both directions, with no configuration required.

**Requests.** A body sent with `Content-Encoding: gzip` is inflated before OpenAPI validation runs,
so the validator, your `TypeMapper` and your handler all see plain bytes. `identity` is accepted as
the no-op it is. A coding the server has not registered — `br`, say — or two codings stacked is
rejected with `415 Unsupported Media Type`, and a corrupt or truncated body with `400 Bad Request`.

Once a body is inflated it no longer matches the headers that described it, so `Content-Encoding` is
hidden from `Request.header(...)` and `Content-Length` reports the inflated size.

Inflation runs under a ceiling, because a few compressed kilobytes can expand into gigabytes:

```java
OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.maxDecompressedRequestBytes(32 * 1024 * 1024) // raises the 10 MiB default; over it, 413
.build();
```

Note this bounds the *inflated* size of a coded body, whatever the coding. It is not a request size
limit — a body that arrives uncompressed is read in full, as it always has been.

**Responses.** A body is gzipped when the client sends `Accept-Encoding: gzip`, the media type is
text-shaped (`text/*`, `application/json`, `application/xml`, `application/yaml`, and the `+json` /
`+xml` / `+yaml` structured suffixes), and it is at least 1 KiB. Below that the coding costs more
than it saves; `application/octet-stream`, images and other already-compressed media are never
coded, and neither is `text/event-stream`, which has to stay unbuffered.

```java
OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.minCompressibleResponseBytes(4096) // raises the 1 KiB default; 0 compresses every eligible body
.build();
```

There is no on/off flag. If a proxy in front of you already terminates compression, set the
threshold above anything this server returns.

`Vary: Accept-Encoding` is set whenever a body *could* have been coded, not only when it was, so
shared caches keep the two forms apart. It is merged into any `Vary` your handler already set.
A handler that sets its own `Content-Encoding` is left alone, and so is a payload the coding
fails to shrink. Statuses that carry no content never get a coding.

Streamed responses (`Response.stream(...)`) are coded as they are written. A length declared by
the sized overload describes the uncoded body, so a coded stream goes out chunked; a stream of
unknown length is coded regardless of the threshold, since measuring it would defeat streaming it.
For the same reason a `HEAD` whose `GET` would be compressed omits `Content-Length` rather than
advertising the uncoded length.

**Other codings.** The library ships gzip only, and so carries no compression dependency. To offer
another, implement `ContentCoding` and register it:

```java
OpenApiServer.builder()
.spec(spec)
.handlers(handlers)
.contentCoding(new ZstdCoding()) // your ContentCoding implementation
.build();
```

The client's weights pick the coding; on a tie, registered codings win over gzip, in registration
order. `decode` and `encode` wrap streams rather than whole bodies, so a decoder that reads lazily
is held to `maxDecompressedRequestBytes` without doing anything itself. `requestContentCoding` and
`responseContentCoding` register one direction only; a request coded with a response-only coding
gets 415. Tokens must be lower-case, and `gzip`, `x-gzip`, `identity` and `*` are reserved.

**Not in this release** (each can land later without breaking the API):

- the `Accept-Encoding` response header RFC 9110 recommends alongside a 415
- compression of the `401`/`403` bodies produced by security scheme enforcement — those bypass the
renderer and are well under any sensible threshold
- per-route or per-operation opt-out

### Graceful shutdown

`OpenApiServer` exposes `stop(int delaySeconds)` for explicit shutdown that waits up to the given
Expand Down Expand Up @@ -1218,6 +1298,12 @@ A few things worth keeping in mind when reading this:
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.
- **gzip changes what an `ETag` identifies.** The library sets none, but a handler that sets a
strong `ETag` would use one entity tag for both the coded and uncoded forms of a body. Use a weak
tag (`W/"..."`), or set `Content-Encoding` yourself to opt that response out of compression.
- **A handler that throws mid-stream yields a valid gzip trailer.** Closing the coded stream
finishes the gzip member, so a client sees a complete-looking short body rather than the framing
error a truncated chunked response would have produced.
- **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.
Expand Down
Loading
Loading