Skip to content

Commit 602c3c6

Browse files
committed
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`.
1 parent 12589f2 commit 602c3c6

14 files changed

Lines changed: 1136 additions & 43 deletions

docs/plans/dynamic-discovering-piglet.md

Lines changed: 368 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.retailsvc.http.internal.ExtrasRouter;
1010
import com.retailsvc.http.internal.FormTypeMapper;
1111
import com.retailsvc.http.internal.PemSslContext;
12+
import com.retailsvc.http.internal.RequestBodyReader;
1213
import com.retailsvc.http.internal.RequestPreparationFilter;
1314
import com.retailsvc.http.internal.ResponseRenderer;
1415
import com.retailsvc.http.internal.SecurityFilter;
@@ -93,9 +94,24 @@ record HandlerConfig(
9394
httpServer.setExecutor(newThreadPerTaskExecutor(ofVirtual().name("http-", 0).factory()));
9495

9596
ResponseRenderer renderer = new ResponseRenderer(bodyMappers);
97+
RequestBodyReader bodyReader =
98+
new RequestBodyReader(RequestBodyReader.DEFAULT_MAX_DECOMPRESSED_BYTES);
9699
boolean anyBindingAtRoot =
97-
wireBindings(httpServer, bindings, bodyMappers, handlerConfig, exceptionHandler, renderer);
98-
wireExtras(httpServer, anyBindingAtRoot, handlerConfig.extras(), exceptionHandler, renderer);
100+
wireBindings(
101+
httpServer,
102+
bindings,
103+
bodyMappers,
104+
handlerConfig,
105+
exceptionHandler,
106+
renderer,
107+
bodyReader);
108+
wireExtras(
109+
httpServer,
110+
anyBindingAtRoot,
111+
handlerConfig.extras(),
112+
exceptionHandler,
113+
renderer,
114+
bodyReader);
99115

100116
httpServer.start();
101117
this.shutdownTimeoutSeconds = shutdownTimeoutSeconds;
@@ -119,13 +135,21 @@ private static boolean wireBindings(
119135
Map<String, TypeMapper> bodyMappers,
120136
HandlerConfig handlerConfig,
121137
ExceptionHandler exceptionHandler,
122-
ResponseRenderer renderer) {
138+
ResponseRenderer renderer,
139+
RequestBodyReader bodyReader) {
123140
boolean anyBindingAtRoot = false;
124141
for (SpecBinding binding : bindings) {
125142
String basePath = Optional.ofNullable(binding.spec().basePath()).orElse("/");
126143
anyBindingAtRoot |= "/".equals(basePath);
127144
wireBinding(
128-
httpServer, basePath, binding, bodyMappers, handlerConfig, exceptionHandler, renderer);
145+
httpServer,
146+
basePath,
147+
binding,
148+
bodyMappers,
149+
handlerConfig,
150+
exceptionHandler,
151+
renderer,
152+
bodyReader);
129153
}
130154
return anyBindingAtRoot;
131155
}
@@ -138,7 +162,8 @@ private static void wireBinding(
138162
Map<String, TypeMapper> bodyMappers,
139163
HandlerConfig handlerConfig,
140164
ExceptionHandler exceptionHandler,
141-
ResponseRenderer renderer) {
165+
ResponseRenderer renderer,
166+
RequestBodyReader bodyReader) {
142167
Map<String, Operation> operationsById =
143168
binding.spec().operations().stream()
144169
.collect(Collectors.toUnmodifiableMap(Operation::operationId, op -> op));
@@ -152,7 +177,8 @@ private static void wireBinding(
152177
bodyMappers,
153178
exceptionHandler,
154179
renderer,
155-
handlerConfig.afterHooks()));
180+
handlerConfig.afterHooks(),
181+
bodyReader));
156182
ctx.getFilters()
157183
.add(
158184
new SecurityFilter(
@@ -174,15 +200,16 @@ private static void wireExtras(
174200
boolean anyBindingAtRoot,
175201
Map<String, RequestHandler> extras,
176202
ExceptionHandler exceptionHandler,
177-
ResponseRenderer renderer) {
203+
ResponseRenderer renderer,
204+
RequestBodyReader bodyReader) {
178205
if (anyBindingAtRoot) {
179206
if (!extras.isEmpty()) {
180207
throw new IllegalStateException(
181208
"extras cannot be registered when a binding owns basePath '/'");
182209
}
183210
return;
184211
}
185-
ExtrasRouter extrasRouter = new ExtrasRouter(extras, renderer);
212+
ExtrasRouter extrasRouter = new ExtrasRouter(extras, renderer, bodyReader);
186213
HttpContext extrasCtx = httpServer.createContext("/", extrasRouter);
187214
extrasCtx.getFilters().add(new ExceptionFilter(exceptionHandler, renderer));
188215
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.retailsvc.http.internal;
2+
3+
import java.util.Locale;
4+
5+
/** Parses {@code Accept-Encoding} request header values (RFC 9110 §12.5.3). */
6+
public final class AcceptEncodingHeader {
7+
8+
private static final String GZIP = "gzip";
9+
private static final String X_GZIP = "x-gzip";
10+
private static final String WILDCARD = "*";
11+
private static final String QUALITY = "q";
12+
private static final double DEFAULT_QUALITY = 1.0;
13+
14+
private AcceptEncodingHeader() {}
15+
16+
/**
17+
* Whether the client accepts a gzip-coded response. A {@code null}, blank, or unrelated header
18+
* yields {@code false}. An explicit {@code gzip;q=0} is a refusal and outranks a positive
19+
* wildcard; a wildcard applies only when gzip is not listed in its own right.
20+
*/
21+
public static boolean acceptsGzip(String header) {
22+
if (header == null) {
23+
return false;
24+
}
25+
Boolean gzipAccepted = null;
26+
Boolean wildcardAccepted = null;
27+
for (String token : header.split(",")) {
28+
String trimmed = token.trim();
29+
if (trimmed.isEmpty()) {
30+
continue;
31+
}
32+
int semi = trimmed.indexOf(';');
33+
String coding =
34+
(semi < 0 ? trimmed : trimmed.substring(0, semi)).trim().toLowerCase(Locale.ROOT);
35+
boolean accepted = quality(semi < 0 ? null : trimmed.substring(semi + 1)) > 0;
36+
if (GZIP.equals(coding) || X_GZIP.equals(coding)) {
37+
gzipAccepted = gzipAccepted == null ? accepted : gzipAccepted || accepted;
38+
} else if (WILDCARD.equals(coding)) {
39+
wildcardAccepted = wildcardAccepted == null ? accepted : wildcardAccepted || accepted;
40+
}
41+
}
42+
if (gzipAccepted != null) {
43+
return gzipAccepted;
44+
}
45+
return wildcardAccepted != null && wildcardAccepted;
46+
}
47+
48+
/**
49+
* Reads the {@code q} weight from a token's parameter list. An absent or unparsable weight is
50+
* read as the default 1.0 — a malformed header should not silently disable compression.
51+
*/
52+
private static double quality(String parameters) {
53+
if (parameters == null) {
54+
return DEFAULT_QUALITY;
55+
}
56+
for (String parameter : parameters.split(";")) {
57+
String trimmed = parameter.trim();
58+
int equals = trimmed.indexOf('=');
59+
if (equals <= 0) {
60+
continue;
61+
}
62+
String name = trimmed.substring(0, equals).trim().toLowerCase(Locale.ROOT);
63+
if (QUALITY.equals(name)) {
64+
try {
65+
return Double.parseDouble(trimmed.substring(equals + 1).trim());
66+
} catch (NumberFormatException e) {
67+
return DEFAULT_QUALITY;
68+
}
69+
}
70+
}
71+
return DEFAULT_QUALITY;
72+
}
73+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.retailsvc.http.internal;
2+
3+
import java.util.Locale;
4+
5+
/** Classifies a request {@code Content-Encoding} into the codings the server can decode. */
6+
public final class ContentEncodingHeader {
7+
8+
private static final String IDENTITY_CODING = "identity";
9+
private static final String GZIP_CODING = "gzip";
10+
private static final String X_GZIP_CODING = "x-gzip";
11+
12+
private ContentEncodingHeader() {}
13+
14+
/** The content coding applied to a request body. */
15+
public enum Coding {
16+
/** No coding, or the explicit {@code identity} no-op. */
17+
NONE,
18+
/** A single gzip coding. */
19+
GZIP,
20+
/** A coding this server cannot decode; the caller renders 415. */
21+
UNSUPPORTED
22+
}
23+
24+
/**
25+
* Classifies the header value. {@code null}, blank and {@code identity} are all {@link
26+
* Coding#NONE}; a single gzip coding — optionally alongside {@code identity} — is {@link
27+
* Coding#GZIP}. Anything else, including two stacked codings, is {@link Coding#UNSUPPORTED}.
28+
*/
29+
public static Coding parse(String header) {
30+
if (header == null) {
31+
return Coding.NONE;
32+
}
33+
Coding result = Coding.NONE;
34+
for (String token : header.split(",")) {
35+
String coding = token.trim().toLowerCase(Locale.ROOT);
36+
if (coding.isEmpty() || IDENTITY_CODING.equals(coding)) {
37+
continue;
38+
}
39+
if (result != Coding.NONE) {
40+
return Coding.UNSUPPORTED;
41+
}
42+
if (GZIP_CODING.equals(coding) || X_GZIP_CODING.equals(coding)) {
43+
result = Coding.GZIP;
44+
} else {
45+
return Coding.UNSUPPORTED;
46+
}
47+
}
48+
return result;
49+
}
50+
}

src/main/java/com/retailsvc/http/internal/ExtrasRouter.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ private record Entry(PathPattern pattern, RequestHandler handler) {}
2121
private final Map<String, RequestHandler> exact;
2222
private final List<Entry> wildcards;
2323
private final ResponseRenderer renderer;
24+
private final RequestBodyReader bodyReader;
2425

25-
public ExtrasRouter(Map<String, RequestHandler> extras, ResponseRenderer renderer) {
26+
public ExtrasRouter(
27+
Map<String, RequestHandler> extras, ResponseRenderer renderer, RequestBodyReader bodyReader) {
2628
this.renderer = renderer;
29+
this.bodyReader = bodyReader;
2730
Map<String, RequestHandler> exactBuilder = new LinkedHashMap<>();
2831
List<Entry> wildcardBuilder = new ArrayList<>();
2932
for (Map.Entry<String, RequestHandler> e : extras.entrySet()) {
@@ -55,18 +58,17 @@ public void handle(HttpExchange exchange) throws IOException {
5558
throw new NotFoundException(exchange.getRequestMethod() + " " + decoded);
5659
}
5760

58-
byte[] body = exchange.getRequestBody().readAllBytes();
61+
RequestBodyReader.Body body = bodyReader.read(exchange);
5962
HttpMethod method = HttpMethod.parse(exchange.getRequestMethod());
60-
var headers = exchange.getRequestHeaders();
6163
Request request =
6264
new Request(
63-
body,
65+
body.bytes(),
6466
null,
6567
null,
6668
null,
6769
Map.of(),
6870
exchange.getRequestURI().getRawQuery(),
69-
headers::getFirst,
71+
body.headerLookup(),
7072
Map.of(),
7173
method);
7274
Response response = hit.handle(request);

src/main/java/com/retailsvc/http/internal/ProblemDetail.java

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.retailsvc.http.internal;
22

33
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
4+
import static java.net.HttpURLConnection.HTTP_ENTITY_TOO_LARGE;
45

56
import com.retailsvc.http.BadRequestException;
67
import com.retailsvc.http.validate.ValidationError;
@@ -79,27 +80,18 @@ private static int depth(String pointer) {
7980
}
8081

8182
private static final Map<Integer, String> TITLES =
82-
Map.of(
83-
HTTP_BAD_REQUEST,
84-
BAD_REQUEST,
85-
401,
86-
"Unauthorized",
87-
403,
88-
"Forbidden",
89-
404,
90-
"Not Found",
91-
405,
92-
"Method Not Allowed",
93-
409,
94-
"Conflict",
95-
410,
96-
"Gone",
97-
412,
98-
"Precondition Failed",
99-
415,
100-
"Unsupported Media Type",
101-
422,
102-
"Unprocessable Content");
83+
Map.ofEntries(
84+
Map.entry(HTTP_BAD_REQUEST, BAD_REQUEST),
85+
Map.entry(401, "Unauthorized"),
86+
Map.entry(403, "Forbidden"),
87+
Map.entry(404, "Not Found"),
88+
Map.entry(405, "Method Not Allowed"),
89+
Map.entry(409, "Conflict"),
90+
Map.entry(410, "Gone"),
91+
Map.entry(412, "Precondition Failed"),
92+
Map.entry(HTTP_ENTITY_TOO_LARGE, "Content Too Large"),
93+
Map.entry(415, "Unsupported Media Type"),
94+
Map.entry(422, "Unprocessable Content"));
10395

10496
private static String titleFor(int status) {
10597
return TITLES.getOrDefault(status, BAD_REQUEST);

0 commit comments

Comments
 (0)