Skip to content

Commit 8a09850

Browse files
committed
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 327cc35 commit 8a09850

23 files changed

Lines changed: 1184 additions & 172 deletions

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ Request flow when `OpenApiServer` boots (`src/main/java/com/retailsvc/http/OpenA
2828
1. `HttpServer` is created on a port with a virtual-thread-per-task executor.
2929
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.
3030
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`.
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`.
3232
- `SecurityFilter` — enforces the spec's `securitySchemes` / `security`, re-binding the `Request` with resolved principals. It writes its 401/403 responses straight to the exchange.
3333
- `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`.
3434

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

3737
Key abstractions:
3838

@@ -42,7 +42,7 @@ Key abstractions:
4242
- `com.retailsvc.http.internal.Router` — two indexes: exact path map and templated path list. Resolves `operationId` + extracted path variables for each request.
4343
- `TypeMapper` — per-media-type request parsing and response writing; registered via `Builder.bodyMapper(...)`, with `GsonTypeMapper` auto-registered when Gson is on the classpath.
4444
- `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.
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.
4646

4747
## Conventions
4848

README.md

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -468,8 +468,8 @@ gzip is handled in both directions, with no configuration required.
468468

469469
**Requests.** A body sent with `Content-Encoding: gzip` is inflated before OpenAPI validation runs,
470470
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`.
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`.
473473

474474
Once a body is inflated it no longer matches the headers that described it, so `Content-Encoding` is
475475
hidden from `Request.header(...)` and `Content-Length` reports the inflated size.
@@ -484,8 +484,8 @@ OpenApiServer.builder()
484484
.build();
485485
```
486486

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

490490
**Responses.** A body is gzipped when the client sends `Accept-Encoding: gzip`, the media type is
491491
text-shaped (`text/*`, `application/json`, `application/xml`, `application/yaml`, and the `+json` /
@@ -506,18 +506,34 @@ threshold above anything this server returns.
506506

507507
`Vary: Accept-Encoding` is set whenever a body *could* have been coded, not only when it was, so
508508
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.
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.
511511

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

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+
518535
**Not in this release** (each can land later without breaking the API):
519536

520-
- brotli, zstd and `deflate`, in either direction
521537
- the `Accept-Encoding` response header RFC 9110 recommends alongside a 415
522538
- compression of the `401`/`403` bodies produced by security scheme enforcement — those bypass the
523539
renderer and are well under any sensible threshold
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.retailsvc.http;
2+
3+
import java.io.IOException;
4+
import java.io.InputStream;
5+
import java.io.OutputStream;
6+
import java.util.Set;
7+
8+
/**
9+
* An HTTP content coding (RFC 9110 §8.4.1) that the server decodes on requests and applies to
10+
* responses. The library ships {@code gzip}; anything else — {@code zstd}, {@code br}, {@code
11+
* deflate} — is supplied by the caller and registered on {@link
12+
* OpenApiServer.Builder#contentCoding(ContentCoding)}, which keeps this library free of any
13+
* compression dependency.
14+
*
15+
* <p>One instance serves every request, so implementations must be immutable and safe for
16+
* concurrent use.
17+
*
18+
* <p>Both methods wrap a stream rather than convert a whole body: return a stream that codes as it
19+
* is read or written. For {@link #decode} this is what bounds the work — the server reads at most
20+
* {@code maxDecompressedRequestBytes} from the stream you return, so a lazy decoder is protected
21+
* from decompression bombs without doing anything, while one that expands the whole body up front
22+
* has already spent what that limit exists to protect.
23+
*/
24+
public interface ContentCoding {
25+
26+
/**
27+
* The {@code Content-Encoding} and {@code Accept-Encoding} token, such as {@code zstd}. Must be a
28+
* lower-case RFC 9110 token; {@code gzip}, {@code x-gzip}, {@code identity} and {@code *} are
29+
* reserved.
30+
*/
31+
String token();
32+
33+
/**
34+
* Further tokens that mean this same coding on the wire, such as a legacy {@code x-} name.
35+
* Recognised on input only; a coded response always announces {@link #token()}.
36+
*/
37+
default Set<String> aliases() {
38+
return Set.of();
39+
}
40+
41+
/**
42+
* Wraps a coded request body in a stream of the decoded bytes. The argument is always held in
43+
* memory, so a read failure means the body is malformed: throw {@link IOException} and the server
44+
* answers 400. Any other exception is treated as a fault in the coding.
45+
*/
46+
InputStream decode(InputStream coded) throws IOException;
47+
48+
/**
49+
* Wraps a response stream so that everything written to the returned stream reaches {@code sink}
50+
* coded. Closing the returned stream must finish the coded payload and close {@code sink}.
51+
*/
52+
OutputStream encode(OutputStream sink) throws IOException;
53+
}

src/main/java/com/retailsvc/http/OpenApiServer.java

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import static java.util.Objects.requireNonNull;
55
import static java.util.concurrent.Executors.newThreadPerTaskExecutor;
66

7+
import com.retailsvc.http.internal.ContentCodings;
78
import com.retailsvc.http.internal.DispatchHandler;
89
import com.retailsvc.http.internal.ExceptionFilter;
910
import com.retailsvc.http.internal.ExtrasRouter;
@@ -247,6 +248,8 @@ public static final class Builder {
247248
private boolean externalAuth = false;
248249
private long maxDecompressedRequestBytes = RequestBodyReader.DEFAULT_MAX_DECOMPRESSED_BYTES;
249250
private long minCompressibleResponseBytes = ResponseRenderer.DEFAULT_MIN_COMPRESSIBLE_BYTES;
251+
private final List<ContentCoding> requestCodings = new ArrayList<>();
252+
private final List<ContentCoding> responseCodings = new ArrayList<>();
250253
private final List<SpecBinding> bindings = new ArrayList<>();
251254

252255
private Builder() {}
@@ -424,6 +427,45 @@ public Builder minCompressibleResponseBytes(long minCompressibleResponseBytes) {
424427
return this;
425428
}
426429

430+
/**
431+
* Registers a content coding the server decodes on requests and applies to responses, alongside
432+
* the built-in gzip. When a client weights several codings equally, the ones registered here
433+
* win over gzip, in registration order; otherwise the client's weights decide. A decoded body
434+
* is still held to {@link #maxDecompressedRequestBytes(long)}.
435+
*
436+
* @throws IllegalArgumentException if a token or alias is not a lower-case RFC 9110 token, or
437+
* is one of the reserved {@code gzip}, {@code x-gzip}, {@code identity} or {@code *}
438+
* @throws IllegalStateException if a token or alias is already registered in either direction
439+
*/
440+
public Builder contentCoding(ContentCoding coding) {
441+
ContentCodings.requireRegistrable(coding, requestCodings);
442+
ContentCodings.requireRegistrable(coding, responseCodings);
443+
requestCodings.add(coding);
444+
responseCodings.add(coding);
445+
return this;
446+
}
447+
448+
/**
449+
* Registers a coding the server only decodes on requests; responses never use it. Validated as
450+
* for {@link #contentCoding(ContentCoding)}.
451+
*/
452+
public Builder requestContentCoding(ContentCoding coding) {
453+
ContentCodings.requireRegistrable(coding, requestCodings);
454+
requestCodings.add(coding);
455+
return this;
456+
}
457+
458+
/**
459+
* Registers a coding the server only applies to responses. A request coded with it is answered
460+
* 415, which keeps a decoder you have no use for off the request path. Validated as for {@link
461+
* #contentCoding(ContentCoding)}.
462+
*/
463+
public Builder responseContentCoding(ContentCoding coding) {
464+
ContentCodings.requireRegistrable(coding, responseCodings);
465+
responseCodings.add(coding);
466+
return this;
467+
}
468+
427469
/**
428470
* Sets the default drain timeout used by {@link OpenApiServer#close()}. {@code 0} (the default)
429471
* stops immediately; positive values wait up to that many seconds for in-flight exchanges to
@@ -463,6 +505,7 @@ public OpenApiServer build() throws IOException {
463505
Map<String, TypeMapper> resolved = resolveBodyMappers(bodyMappers);
464506
ExceptionHandler effectiveExceptionHandler =
465507
exceptionHandler != null ? exceptionHandler : Handlers.defaultExceptionHandler();
508+
ContentCodings codings = ContentCodings.of(requestCodings, responseCodings);
466509
HandlerConfig handlerConfig =
467510
new HandlerConfig(
468511
interceptors,
@@ -471,8 +514,8 @@ public OpenApiServer build() throws IOException {
471514
extras,
472515
externalAuth,
473516
List.copyOf(afterHooks),
474-
new RequestBodyReader(maxDecompressedRequestBytes),
475-
new ResponseRenderer(resolved, minCompressibleResponseBytes));
517+
new RequestBodyReader(maxDecompressedRequestBytes, codings.decoders()),
518+
new ResponseRenderer(resolved, minCompressibleResponseBytes, codings.encoders()));
476519
int resolvedPort = resolvePort();
477520
SSLContext sslContext =
478521
httpsCertChain != null ? PemSslContext.load(httpsCertChain, httpsPrivateKey) : null;
Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,87 @@
11
package com.retailsvc.http.internal;
22

3+
import com.retailsvc.http.ContentCoding;
4+
import java.util.HashMap;
5+
import java.util.List;
36
import java.util.Locale;
7+
import java.util.Map;
8+
import java.util.Optional;
49

510
/** Parses {@code Accept-Encoding} request header values (RFC 9110 §12.5.3). */
611
public final class AcceptEncodingHeader {
712

13+
private static final double DEFAULT_WEIGHT = 1.0;
14+
815
private AcceptEncodingHeader() {}
916

1017
/**
11-
* Whether the client accepts a gzip-coded response. A {@code null}, blank, or unrelated header
12-
* yields {@code false}. An explicit {@code gzip;q=0} is a refusal and outranks a positive
13-
* wildcard; a wildcard applies only when gzip is not listed in its own right.
18+
* Chooses the coding the client weights highest among {@code candidates}, which arrive in server
19+
* preference order, so the earlier candidate wins a tie. Empty means send the body uncoded — also
20+
* the answer for an absent header, since not asking for a coding is not the same as accepting
21+
* any.
22+
*
23+
* <p>A coding the client names outranks its {@code *} entry, so {@code gzip;q=0} refuses gzip
24+
* even beside a positive wildcard. A weight of zero refuses, and a name listed twice takes its
25+
* higher weight.
1426
*/
15-
public static boolean acceptsGzip(String header) {
16-
if (header == null) {
17-
return false;
27+
public static Optional<ContentCoding> select(String header, List<ContentCoding> candidates) {
28+
if (header == null || candidates.isEmpty()) {
29+
return Optional.empty();
30+
}
31+
Map<String, Double> weights = weights(header);
32+
ContentCoding best = null;
33+
double bestWeight = 0;
34+
for (ContentCoding candidate : candidates) {
35+
double weight = weightOf(candidate, weights);
36+
if (weight > bestWeight) {
37+
best = candidate;
38+
bestWeight = weight;
39+
}
1840
}
19-
boolean gzipSeen = false;
20-
boolean gzipAccepted = false;
21-
boolean wildcardAccepted = false;
41+
return Optional.ofNullable(best);
42+
}
43+
44+
/** The weight the client gave each coding it listed, keeping the higher for a repeated one. */
45+
private static Map<String, Double> weights(String header) {
46+
Map<String, Double> weights = new HashMap<>();
2247
for (String token : header.split(",")) {
2348
int semi = token.indexOf(';');
2449
String coding = (semi < 0 ? token : token.substring(0, semi)).trim().toLowerCase(Locale.ROOT);
25-
boolean accepted = positiveWeight(token);
26-
if ("gzip".equals(coding) || "x-gzip".equals(coding)) {
27-
gzipSeen = true;
28-
gzipAccepted |= accepted;
29-
} else if ("*".equals(coding)) {
30-
wildcardAccepted |= accepted;
50+
if (!coding.isEmpty()) {
51+
weights.merge(coding, weight(token), Math::max);
3152
}
3253
}
33-
return gzipSeen ? gzipAccepted : wildcardAccepted;
54+
return weights;
55+
}
56+
57+
/** A candidate's weight from its own name or an alias, and only failing both, the wildcard. */
58+
private static double weightOf(ContentCoding coding, Map<String, Double> weights) {
59+
Double listed = weights.get(coding.token());
60+
for (String alias : coding.aliases()) {
61+
Double aliased = weights.get(alias);
62+
if (aliased != null && (listed == null || aliased > listed)) {
63+
listed = aliased;
64+
}
65+
}
66+
if (listed != null) {
67+
return listed;
68+
}
69+
return weights.getOrDefault("*", 0.0);
3470
}
3571

3672
/**
37-
* Whether the token's {@code q} weight admits the coding. An absent or unparsable weight reads as
38-
* the default 1.0 — a malformed header should not silently disable compression.
73+
* Reads the {@code q} weight from a token. An absent or unparsable weight reads as the default
74+
* 1.0 — a malformed header should not silently disable compression.
3975
*/
40-
private static boolean positiveWeight(String token) {
76+
private static double weight(String token) {
4177
String weight = ContentTypeHeader.parameter(token, "q").orElse(null);
4278
if (weight == null) {
43-
return true;
79+
return DEFAULT_WEIGHT;
4480
}
4581
try {
46-
return Double.parseDouble(weight) > 0;
82+
return Double.parseDouble(weight);
4783
} catch (NumberFormatException _) {
48-
return true;
84+
return DEFAULT_WEIGHT;
4985
}
5086
}
5187
}

0 commit comments

Comments
 (0)