Skip to content

Commit 72333f9

Browse files
authored
feat: Gzip content encoding (#126)
* feat: Decompress gzip request bodies Requests carrying `Content-Encoding: gzip` are now inflated before OpenAPI body validation runs, so the validator, the type mappers and handlers all see plain bytes. Inflation runs through a counting loop under a hard cap (10 MiB) so a small compressed payload cannot expand into an OOM. Exceeding the cap yields 413, an unsupported coding yields 415, and a malformed or truncated gzip stream yields 400 — all as problem+json. Only ZipException and EOFException are converted; a genuine socket failure stays an IOException and still renders 500. Once inflated, the body no longer matches the request headers that described it, so the handler's header view hides `Content-Encoding` and reports the inflated `Content-Length`. * feat: Compress responses when the client accepts gzip Response bodies are now gzipped when the client sends `Accept-Encoding: gzip`, the media type is text-shaped, and the payload clears a 1 KiB threshold. Compressing tiny payloads costs more than it saves, and already-compressed media gains nothing. The step lives in ResponseRenderer, the one point every response flows through, so problem+json errors, the health endpoint, served specs and 404s are all covered. A handler that coded the body itself is left alone, as is a payload gzip fails to shrink. Statuses that carry no content never get a coding. Streamed bodies are deflated as they are written. A sized body's declared length measures the uncoded form, so a coded stream degrades to chunked; a body of unknown length is coded regardless of the threshold, since measuring it would defeat streaming it. `Vary: Accept-Encoding` is announced whenever a body could have been coded, not only when it was, and is merged into any Vary the handler already set rather than added as a second field line. Two fixes fall out of routing bodiless responses through the same path: they now carry the Content-Type the handler declared, and they drop a hand-declared Content-Length when the matching GET would have been compressed — HEAD must not advertise a length the coded body will not match. * feat: Make the gzip size limits configurable `maxDecompressedRequestBytes` moves the inflation ceiling off its 10 MiB default, for services whose legitimate payloads are larger. It is capped at Integer.MAX_VALUE because the inflated body is buffered into an array. `minimumGzipResponseBytes` moves the compression threshold off its 1 KiB default. Setting it to 0 compresses every compressible body; setting it above any response this server produces turns compression off, which is what a service behind a proxy that already terminates compression wants. * test: Cover gzip content encoding end to end Drives both directions over a real socket against the existing openapi.json fixture: `text-echo` echoes its body, so one call exercises request inflation and response coding together, and the spec resource route covers the streamed path. `java.net.http.HttpClient` neither sends `Accept-Encoding` nor decodes a coded response, so the tests set the header themselves and read bytes. That also makes `responseIsNotGzippedWithoutAcceptEncoding` the guard proving compression stays invisible to every other integration test. * 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. * test: Cover the remaining content-coding branches Fills the gaps JaCoCo flagged: repeated codings in one Accept-Encoding header, weight parameters mixed with others, a valueless parameter, 304 responses, an unparsable hand-set Content-Length, and streams whose handler supplied its own Content-Type or Content-Encoding. Branch coverage on AcceptEncodingHeader goes 70% to 93% and on ResponseRenderer 83% to 87%, keeping the new code clear of the Sonar new-code gate. * docs: Clarify that the gzip snippets show overrides, not defaults Both builder examples put the default in a comment beside a different literal, so the comment read as if it were annotating the value in the call. Say plainly that the call raises the default. * refactor: Collapse the duplicated gzip decision in the renderer All three render paths repeated the same guard — already coded, not compressible, or a status that carries no content — then announced Vary, then checked threshold and Accept-Encoding. That is now one shouldCompress method the three paths share, with a negative length meaning "unknown, treat as over the threshold" so streams keep their semantics. Also drops the single-argument ResponseRenderer constructor, which no longer had a caller in main, and trims incidental weight in the header parsers: the boxed tri-state in AcceptEncodingHeader becomes two plain booleans, the compressible-suffix Set becomes three endsWith calls, and single-use string constants are inlined to match ContentTypeHeader. 530 to 498 lines across the five files; behaviour unchanged. * refactor: Carry the gzip collaborators instead of plumbing their sizes HandlerConfig now holds the RequestBodyReader and the ResponseRenderer themselves rather than the two longs they are built from, and Builder constructs both. Since HandlerConfig already reached every wiring method, the extra parameters threaded through wireBindings, wireBinding and wireExtras all go away, and with them both java:S107 suppressions — the parameter counts were the signal that the numbers were at the wrong altitude. Also reuses ContentTypeHeader.parameter for the Accept-Encoding q weight instead of hand-rolling a second parameter parser, replaces the capped inflate loop with a single bounded readNBytes, and drops the gzipStream alias for the JDK constructor it wrapped. The five gzip files go 530 to 470 lines and OpenApiServer sheds 36. * refactor: Trim the last of the gzip code Collapses the byte-body content-type resolution into two expressions and inlines the remaining single-use string constants. * fix: Restore the shutdownTimeoutSeconds javadoc The two content-coding setters were inserted between that javadoc and the method it documents, so it bound to nothing and shutdownTimeoutSeconds lost its documentation. Moves it back and folds the cap's two range checks into the one range they describe. Also un-nests the ternary that resolving a byte body's content type had grown, which SonarQube flags as S3358 on new code. * fix: Drop a handler's Content-Length when a stream is compressed A compressed stream goes out chunked, and the JDK's chunked branch sets Transfer-encoding without clearing a Content-Length the handler put on the response — so both framing headers reached the wire together. Before this branch a sized stream passed its real length, which made the JDK overwrite that header, so the conflict is new. The declared length also describes the uncompressed body, so it is wrong on the wire regardless of framing. renderEmpty already removed it for the same reason; renderStream now does too. * ci: Run the pre-commit formatter on the project's JDK The pre-commit workflow set up Python but no Java, so google-java-format ran on the runner's default JDK 17 and could not parse this project's Java 25 sources — every file using an unnamed `_` binding or a record pattern failed to parse. Those constructs predate this branch; the job only fails when a pull request happens to touch such a file. Adds the same setup-java step pull_request.yaml already uses, keyed off .java-version, and bumps extenda/pre-commit-hooks to v0.16.1. Verified locally across the matrix: on JDK 17 the formatter fails with either jar version (1.28.0 cannot parse `_`, 1.36.1 throws LinkageError); on JDK 25 both pass. The JDK is the fix, the bump is housekeeping. * fix: Resolve the SonarCloud findings on the gzip code - RequestBodyReader: do the clamp subtraction in long so the int arithmetic cannot be read as a narrowing hazard (S2184). The value is unchanged. - GzipIT: assert with hasSizeLessThan on the array rather than on its length field. - RequestBodyReaderTest: drop a throws IOException the body cannot throw, since the gzip call sits inside the assertion lambda. - RequestPreparationFilterTest: static-import the Mockito DSL, matching the convention the rest of the suite already follows. * refactor: Name the response threshold for what it measures minimumGzipResponseBytes becomes minCompressibleResponseBytes, and ResponseRenderer.DEFAULT_MINIMUM_GZIP_BYTES becomes DEFAULT_MIN_COMPRESSIBLE_BYTES. The threshold is compared against the uncompressed body before any coding is chosen, so it neither depends on gzip nor measures a compressed size. "Compressible" says what it does: a body smaller than this is not worth coding. It now mirrors maxDecompressedRequestBytes - min and max, and both measure plain bytes. Neither name has been released, and every push to master publishes to Maven Central, so this is the last point at which the rename is free rather than a breaking change to a public builder method. * feat: Let callers register their own content codings Adds ContentCoding, a public extension point for HTTP content codings, so a service can offer zstd, brotli or deflate without this library taking a compression dependency. gzip becomes the built-in instance of the same interface. Codings are registered on the builder with contentCoding, or with requestContentCoding / responseContentCoding for one direction only; a request coded with a response-only coding is answered 415. The client's q-weights pick the response coding, and on a tie registered codings win over gzip in registration order. The interface wraps streams rather than whole bodies. That keeps the decompression cap in the library: the server reads at most maxDecompressedRequestBytes from whatever stream a coding returns, so a lazy decoder is bounded without doing anything itself. Registration fails fast on reserved tokens (gzip, x-gzip, identity, *), on duplicates within a direction, and on anything that is not a lower-case RFC 9110 token. Tokens are written verbatim into response headers, so that check is what keeps them free of injected CR/LF.
1 parent 8af57b6 commit 72333f9

31 files changed

Lines changed: 3337 additions & 107 deletions

‎.github/workflows/pre-commit.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ jobs:
1212
- name: Setup Python
1313
uses: actions/setup-python@v7
1414

15+
- name: Setup Java
16+
uses: actions/setup-java@v6
17+
with:
18+
distribution: temurin
19+
java-version-file: .java-version
20+
1521
- name: Run pre-commit
1622
uses: pre-commit/actions@v3.0.1
1723
with:

‎.pre-commit-config.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ repos:
1717
hooks:
1818
- id: editorconfig-checker
1919
- repo: https://github.com/extenda/pre-commit-hooks
20-
rev: v0.15.0
20+
rev: v0.16.1
2121
hooks:
2222
- id: google-java-formatter
2323
- id: commitlint

‎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 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`.
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 content 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.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.
4346

4447
## Conventions
4548

‎README.md‎

Lines changed: 86 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,83 @@ 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. A coding the server has not registered — `br`, say — or two codings stacked is
472+
rejected with `415 Unsupported Media Type`, and a corrupt or truncated body 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) // raises the 10 MiB default; over it, 413
484+
.build();
485+
```
486+
487+
Note this bounds the *inflated* size of a coded body, whatever the coding. It is not a request size
488+
limit — a body that 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+
.minCompressibleResponseBytes(4096) // raises the 1 KiB default; 0 compresses every eligible body
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 the coding
510+
fails to shrink. Statuses that carry no content never get a coding.
511+
512+
Streamed responses (`Response.stream(...)`) are coded 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+
**Other codings.** The library ships gzip only, and so carries no compression dependency. To offer
519+
another, implement `ContentCoding` and register it:
520+
521+
```java
522+
OpenApiServer.builder()
523+
.spec(spec)
524+
.handlers(handlers)
525+
.contentCoding(new ZstdCoding()) // your ContentCoding implementation
526+
.build();
527+
```
528+
529+
The client's weights pick the coding; on a tie, registered codings win over gzip, in registration
530+
order. `decode` and `encode` wrap streams rather than whole bodies, so a decoder that reads lazily
531+
is held to `maxDecompressedRequestBytes` without doing anything itself. `requestContentCoding` and
532+
`responseContentCoding` register one direction only; a request coded with a response-only coding
533+
gets 415. Tokens must be lower-case, and `gzip`, `x-gzip`, `identity` and `*` are reserved.
534+
535+
**Not in this release** (each can land later without breaking the API):
536+
537+
- the `Accept-Encoding` response header RFC 9110 recommends alongside a 415
538+
- compression of the `401`/`403` bodies produced by security scheme enforcement — those bypass the
539+
renderer and are well under any sensible threshold
540+
- per-route or per-operation opt-out
541+
462542
### Graceful shutdown
463543

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

0 commit comments

Comments
 (0)