Skip to content

Commit 5cbb498

Browse files
committed
docs: Document gzip content encoding
Adds a "Content encoding" section under Server configuration covering both directions, the two limits and their defaults, the media types that qualify, and the deliberate non-goals. Adds Caveats entries for the two consequences a handler author can be surprised by: a strong ETag now spans two byte streams, and throwing mid-stream produces a valid gzip trailer over a short body rather than a framing error. Also corrects the architecture notes in CLAUDE.md, which still described a filter chain that no longer exists — ExceptionFilter on the spec context, the request body stashed as an exchange attribute, and a static `Request.bytes(exchange)` helper — and mentioned neither SecurityFilter nor ExtrasRouter.
1 parent eabbe31 commit 5cbb498

3 files changed

Lines changed: 83 additions & 10 deletions

File tree

CLAUDE.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,23 @@ Java 25 is required (see `.java-version`). The server uses thread-per-request wi
2626
Request flow when `OpenApiServer` boots (`src/main/java/com/retailsvc/http/OpenApiServer.java`):
2727

2828
1. `HttpServer` is created on a port with a virtual-thread-per-task executor.
29-
2. A single `HttpContext` is registered at `spec.basePath()` (the first `servers[].url` path from the OpenAPI doc). A catch-all `/` context returns 404.
30-
3. Three filters run in order on every request:
31-
- `ExceptionFilter` — wraps the chain; delegates uncaught exceptions to the user-supplied `ExceptionHandler` (default in `Handlers`).
32-
- `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.
33-
- `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`.
29+
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.
30+
3. On a binding context, two filters run in order, then the handler:
31+
- `RequestPreparationFilter` — reads the request body through `RequestBodyReader` (which inflates a gzip `Content-Encoding` 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`.
32+
- `SecurityFilter` — enforces the spec's `securitySchemes` / `security`, re-binding the `Request` with resolved principals. It writes its 401/403 responses straight to the exchange.
33+
- `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`.
34+
35+
Every response except `SecurityFilter`'s rejections is written by `ResponseRenderer`, which is also where response gzip coding is applied.
3436

3537
Key abstractions:
3638

3739
- `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.
3840
- 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.
39-
- `com.retailsvc.http.validate.DefaultValidator` — single class using `switch` pattern-match over `Schema` subtypes. Validation failures produce RFC 7807 `application/problem+json` 400 responses.
41+
- `com.retailsvc.http.validate.DefaultValidator` — single class using `switch` pattern-match over `Schema` subtypes. Validation failures produce RFC 9457 `application/problem+json` 400 responses.
4042
- `com.retailsvc.http.internal.Router` — two indexes: exact path map and templated path list. Resolves `operationId` + extracted path variables for each request.
41-
- `JsonMapper``@FunctionalInterface`; single method `Object mapFrom(byte[])`. Callers supply a lambda (see README).
42-
- `com.retailsvc.http.Request` — static helper; `Request.bytes(exchange)` returns raw body bytes, `Request.parsed(exchange)` returns the `Object` produced by the `JsonMapper`.
43+
- `TypeMapper` — per-media-type request parsing and response writing; registered via `Builder.bodyMapper(...)`, with `GsonTypeMapper` auto-registered when Gson is on the classpath.
44+
- `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`.
45+
- `com.retailsvc.http.internal.RequestBodyReader` / `ResponseCompression` — inbound and outbound gzip. See the README's "Content encoding" section for the policy.
4346

4447
## Conventions
4548

README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ endpoints declared in an OpenAPI 3.1.x specification. Handlers are pure function
2323
- [Body parsers and response writers](#body-parsers-and-response-writers)
2424
- [Server configuration](#server-configuration)
2525
- [HTTPS](#https)
26+
- [Content encoding](#content-encoding)
2627
- [Interceptors and response decorators](#interceptors-and-response-decorators)
2728
- [After-response hooks](#after-response-hooks)
2829
- [Security](#security)
@@ -48,6 +49,8 @@ endpoints declared in an OpenAPI 3.1.x specification. Handlers are pure function
4849
- OpenAPI `securitySchemes` and `security` enforcement (`apiKey`, `http bearer`, `http basic`),
4950
with an opt-out for sidecar / gateway authentication
5051
- RFC 9457 `application/problem+json` validation errors with an `errors[]` array of JSON-Pointers to the failing locations
52+
- Transparent gzip: request bodies are inflated under a zip-bomb ceiling, responses are compressed
53+
when the client accepts it and the payload is worth it
5154
- Built on the JDK's native `HttpServer` with thread-per-request behaviour using virtual threads
5255

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

465+
### Content encoding
466+
467+
gzip is handled in both directions, with no configuration required.
468+
469+
**Requests.** A body sent with `Content-Encoding: gzip` is inflated before OpenAPI validation runs,
470+
so the validator, your `TypeMapper` and your handler all see plain bytes. `identity` is accepted as
471+
the no-op it is. Any other coding — `br`, `deflate`, or two codings stacked — is rejected with
472+
`415 Unsupported Media Type`, and a corrupt or truncated gzip stream with `400 Bad Request`.
473+
474+
Once a body is inflated it no longer matches the headers that described it, so `Content-Encoding` is
475+
hidden from `Request.header(...)` and `Content-Length` reports the inflated size.
476+
477+
Inflation runs under a ceiling, because a few compressed kilobytes can expand into gigabytes:
478+
479+
```java
480+
OpenApiServer.builder()
481+
.spec(spec)
482+
.handlers(handlers)
483+
.maxDecompressedRequestBytes(32 * 1024 * 1024) // default 10 MiB; over it, 413
484+
.build();
485+
```
486+
487+
Note this bounds the *inflated* size of a gzip body. It is not a request size limit — a body that
488+
arrives uncompressed is read in full, as it always has been.
489+
490+
**Responses.** A body is gzipped when the client sends `Accept-Encoding: gzip`, the media type is
491+
text-shaped (`text/*`, `application/json`, `application/xml`, `application/yaml`, and the `+json` /
492+
`+xml` / `+yaml` structured suffixes), and it is at least 1 KiB. Below that the coding costs more
493+
than it saves; `application/octet-stream`, images and other already-compressed media are never
494+
coded, and neither is `text/event-stream`, which has to stay unbuffered.
495+
496+
```java
497+
OpenApiServer.builder()
498+
.spec(spec)
499+
.handlers(handlers)
500+
.minimumGzipResponseBytes(4096) // default 1024; 0 compresses everything compressible
501+
.build();
502+
```
503+
504+
There is no on/off flag. If a proxy in front of you already terminates compression, set the
505+
threshold above anything this server returns.
506+
507+
`Vary: Accept-Encoding` is set whenever a body *could* have been coded, not only when it was, so
508+
shared caches keep the two forms apart. It is merged into any `Vary` your handler already set.
509+
A handler that sets its own `Content-Encoding` is left alone, and so is a payload gzip fails to
510+
shrink. Statuses that carry no content never get a coding.
511+
512+
Streamed responses (`Response.stream(...)`) are deflated as they are written. A length declared by
513+
the sized overload describes the uncoded body, so a coded stream goes out chunked; a stream of
514+
unknown length is coded regardless of the threshold, since measuring it would defeat streaming it.
515+
For the same reason a `HEAD` whose `GET` would be compressed omits `Content-Length` rather than
516+
advertising the uncoded length.
517+
518+
**Not in this release** (each can land later without breaking the API):
519+
520+
- brotli, zstd and `deflate`, in either direction
521+
- the `Accept-Encoding` response header RFC 9110 recommends alongside a 415
522+
- compression of the `401`/`403` bodies produced by security scheme enforcement — those bypass the
523+
renderer and are well under any sensible threshold
524+
- per-route or per-operation opt-out
525+
462526
### Graceful shutdown
463527

464528
`OpenApiServer` exposes `stop(int delaySeconds)` for explicit shutdown that waits up to the given
@@ -1218,6 +1282,12 @@ A few things worth keeping in mind when reading this:
12181282
JDK `HttpExchange`. A future enhancement could plug in a higher-throughput backend (Jetty,
12191283
Helidon Níma, Netty) by writing a new adapter behind `com.retailsvc.http.internal` while
12201284
leaving handlers untouched.
1285+
- **gzip changes what an `ETag` identifies.** The library sets none, but a handler that sets a
1286+
strong `ETag` would use one entity tag for both the coded and uncoded forms of a body. Use a weak
1287+
tag (`W/"..."`), or set `Content-Encoding` yourself to opt that response out of compression.
1288+
- **A handler that throws mid-stream yields a valid gzip trailer.** Closing the coded stream
1289+
finishes the gzip member, so a client sees a complete-looking short body rather than the framing
1290+
error a truncated chunked response would have produced.
12211291
- **Per-request state uses `ScopedValue`** (Java 25, JEP 506). This matters if a handler
12221292
offloads work to an executor that's not a `StructuredTaskScope`-managed child thread: the
12231293
`ScopedValue` is not visible there, so the handler must capture the values it needs (e.g.

docs/plans/dynamic-discovering-piglet.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,15 +304,15 @@ as it completes.
304304

305305
### Task 6 — docs
306306

307-
- [ ] **Step 11** README: `### Request decompression` and `### Response compression` under
307+
- [x] **Step 11** README: `### Request decompression` and `### Response compression` under
308308
`## Server configuration`, a TOC entry, a `## Highlights` bullet, and a **"Not in this
309309
release"** list matching the HTTPS section's convention — brotli/deflate/zstd,
310310
`Accept-Encoding` on 415 responses (RFC 9110 §15.5.16 SHOULD; `BadRequestException` carries no
311311
headers), compression of `SecurityFilter` 401/403 bodies, per-route opt-out. Plus `Caveats`
312312
bullets: the cap bounds *inflated* bytes only and is not a request size limit; a strong `ETag`
313313
set by a handler now spans two byte streams; a handler that throws mid-stream yields a valid
314314
gzip trailer over truncated content rather than a framing error.
315-
- [ ] **Step 12** Correct the stale request-flow description in `CLAUDE.md` — it describes three
315+
- [x] **Step 12** Correct the stale request-flow description in `CLAUDE.md` — it describes three
316316
filters including `ExceptionFilter` on the spec context, exchange-attribute body stashing and
317317
a `Request.bytes(exchange)` static helper, none of which match the current code, and it
318318
mentions neither `SecurityFilter` nor `ExtrasRouter`.

0 commit comments

Comments
 (0)