Skip to content

Commit 9f779a1

Browse files
committed
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.
1 parent 602c3c6 commit 9f779a1

6 files changed

Lines changed: 675 additions & 10 deletions

File tree

docs/plans/dynamic-discovering-piglet.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,12 +246,12 @@ as it completes.
246246

247247
### Task 3 — response compression
248248

249-
- [ ] **Step 6** `internal/ResponseCompressionTest``nullContentTypeIsNotCompressible`,
249+
- [x] **Step 6** `internal/ResponseCompressionTest``nullContentTypeIsNotCompressible`,
250250
`jsonIsCompressible`, `problemJsonIsCompressible`, `yamlIsCompressible`,
251251
`textPlainWithCharsetIsCompressible`, `xmlSuffixIsCompressible`,
252252
`octetStreamIsNotCompressible`, `imagePngIsNotCompressible`, `eventStreamIsNotCompressible`,
253253
`gzipRoundTripsBytes`, `gzipStreamRoundTripsBytes`. Then implement.
254-
- [ ] **Step 7** `internal/ResponseRendererTest` — the repo's **first direct renderer test**, so it
254+
- [x] **Step 7** `internal/ResponseRendererTest` — the repo's **first direct renderer test**, so it
255255
starts with baseline coverage of behaviour it is about to change
256256
(`writesBytesWithContentLength`, `writesNullBodyWithMinusOne`, `writesSizedStreamWithLength`),
257257
then: `compressesJsonBodyOverThreshold`, `setsContentEncodingGzipWhenCompressed`,
@@ -267,7 +267,7 @@ as it completes.
267267
`DispatchHandlerTest.stubExchange()` — it stubs only `getResponseHeaders()` today, so an
268268
unstubbed `getRequestHeaders()` returns null. Fixing the stub is honest and removes a fragile
269269
dependency on evaluation order in main code.
270-
- [ ] **Step 8** Streaming and null bodies, same test class:
270+
- [x] **Step 8** Streaming and null bodies, same test class:
271271
`compressesChunkedStreamWhenAcceptEncodingPresent`,
272272
`degradesSizedStreamToChunkedWhenCompressed`, `skipsCompressionForSizedStreamBelowThreshold`,
273273
`skipsCompressionForNullContentTypeStream`,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package com.retailsvc.http.internal;
2+
3+
import java.io.ByteArrayOutputStream;
4+
import java.io.IOException;
5+
import java.io.OutputStream;
6+
import java.util.Set;
7+
import java.util.zip.GZIPOutputStream;
8+
9+
/** Response content-coding policy, and the gzip primitives the renderer writes through. */
10+
public final class ResponseCompression {
11+
12+
private static final String TEXT_PREFIX = "text/";
13+
private static final String EVENT_STREAM = "text/event-stream";
14+
15+
private static final Set<String> COMPRESSIBLE_TYPES =
16+
Set.of(
17+
"application/json",
18+
"application/xml",
19+
"application/yaml",
20+
"application/x-yaml",
21+
"application/javascript",
22+
"application/x-ndjson");
23+
24+
private static final Set<String> COMPRESSIBLE_SUFFIXES = Set.of("+json", "+xml", "+yaml");
25+
26+
private ResponseCompression() {}
27+
28+
/**
29+
* Whether a response of this content type is worth gzipping. Already-compressed payloads gain
30+
* nothing, and {@code text/event-stream} must stay unbuffered so each event reaches the client as
31+
* it is written.
32+
*
33+
* <p>An absent content type is never compressible. It cannot be resolved through {@link
34+
* ContentTypeHeader#mediaType} here, because that reads {@code null} as {@code application/json}.
35+
*/
36+
public static boolean isCompressible(String contentType) {
37+
if (contentType == null) {
38+
return false;
39+
}
40+
String mediaType = ContentTypeHeader.mediaType(contentType);
41+
if (mediaType.startsWith(TEXT_PREFIX)) {
42+
return !EVENT_STREAM.equals(mediaType);
43+
}
44+
for (String suffix : COMPRESSIBLE_SUFFIXES) {
45+
if (mediaType.endsWith(suffix)) {
46+
return true;
47+
}
48+
}
49+
return COMPRESSIBLE_TYPES.contains(mediaType);
50+
}
51+
52+
/** Deflates {@code body} into a complete gzip member. */
53+
public static byte[] gzip(byte[] body) throws IOException {
54+
ByteArrayOutputStream out = new ByteArrayOutputStream();
55+
try (GZIPOutputStream gzip = new GZIPOutputStream(out)) {
56+
gzip.write(body);
57+
}
58+
return out.toByteArray();
59+
}
60+
61+
/**
62+
* Wraps {@code out} so a streamed body is deflated as it is written. Closing the returned stream
63+
* writes the gzip trailer and releases the deflater's native memory, so the caller must close it.
64+
*/
65+
public static OutputStream gzipStream(OutputStream out) throws IOException {
66+
return new GZIPOutputStream(out);
67+
}
68+
}

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

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

3+
import static java.net.HttpURLConnection.HTTP_NOT_MODIFIED;
4+
import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
5+
import static java.net.HttpURLConnection.HTTP_OK;
6+
import static java.net.HttpURLConnection.HTTP_PARTIAL;
7+
import static java.net.HttpURLConnection.HTTP_RESET;
8+
39
import com.retailsvc.http.Response;
410
import com.retailsvc.http.TypeMapper;
511
import com.sun.net.httpserver.Headers;
@@ -12,14 +18,30 @@
1218
/** Writes a {@link Response} to an {@link HttpExchange}. */
1319
public final class ResponseRenderer {
1420

21+
/** Default smallest body worth gzipping: 1 KiB. */
22+
public static final long DEFAULT_MINIMUM_GZIP_BYTES = 1024;
23+
1524
private static final String CONTENT_TYPE = "Content-Type";
25+
private static final String CONTENT_ENCODING = "Content-Encoding";
26+
private static final String CONTENT_LENGTH = "Content-Length";
27+
private static final String VARY = "Vary";
28+
private static final String ACCEPT_ENCODING = "Accept-Encoding";
29+
private static final String GZIP = "gzip";
30+
private static final long UNKNOWN_LENGTH = -1;
31+
private static final long CHUNKED = 0;
1632
private static final String DEFAULT_JSON = "application/json";
1733
private static final String OCTET_STREAM = "application/octet-stream";
1834

1935
private final Map<String, TypeMapper> mappers;
36+
private final long minimumGzipBytes;
2037

2138
public ResponseRenderer(Map<String, TypeMapper> mappers) {
39+
this(mappers, DEFAULT_MINIMUM_GZIP_BYTES);
40+
}
41+
42+
public ResponseRenderer(Map<String, TypeMapper> mappers, long minimumGzipBytes) {
2243
this.mappers = Map.copyOf(mappers);
44+
this.minimumGzipBytes = minimumGzipBytes;
2345
}
2446

2547
public void render(HttpExchange exchange, Response response) throws IOException {
@@ -31,7 +53,7 @@ public void render(HttpExchange exchange, Response response) throws IOException
3153
int status = response.status();
3254

3355
if (body == null) {
34-
exchange.sendResponseHeaders(status, -1);
56+
renderEmpty(exchange, headers, status, response.contentType());
3557
} else if (body instanceof BodyWriter writer) {
3658
renderStream(exchange, headers, status, response.contentType(), writer);
3759
} else {
@@ -40,19 +62,78 @@ public void render(HttpExchange exchange, Response response) throws IOException
4062
}
4163
}
4264

43-
private static void renderStream(
65+
/**
66+
* Writes a bodiless response. Nothing can be coded here, but the response still has to say how a
67+
* body would have been coded: a length declared for a body the client will fetch separately would
68+
* describe the uncoded form, which is not what a coded {@code GET} would return.
69+
*/
70+
private void renderEmpty(HttpExchange exchange, Headers headers, int status, String contentType)
71+
throws IOException {
72+
if (contentType != null && !headers.containsKey(CONTENT_TYPE)) {
73+
headers.add(CONTENT_TYPE, contentType);
74+
}
75+
if (!headers.containsKey(CONTENT_ENCODING)
76+
&& ResponseCompression.isCompressible(contentType)
77+
&& bodyAllowed(status)) {
78+
addVary(headers);
79+
if (declaredLength(headers) >= minimumGzipBytes && acceptsGzip(exchange)) {
80+
headers.remove(CONTENT_LENGTH);
81+
}
82+
}
83+
exchange.sendResponseHeaders(status, -1);
84+
}
85+
86+
private void renderStream(
4487
HttpExchange exchange, Headers headers, int status, String contentType, BodyWriter writer)
4588
throws IOException {
4689
if (contentType != null && !headers.containsKey(CONTENT_TYPE)) {
4790
headers.add(CONTENT_TYPE, contentType);
4891
}
49-
long length = writer instanceof BodyWriter.Sized sized ? sized.length() : 0;
50-
exchange.sendResponseHeaders(status, length);
92+
long declared = writer instanceof BodyWriter.Sized sized ? sized.length() : UNKNOWN_LENGTH;
93+
if (compressStream(exchange, headers, status, contentType, declared)) {
94+
headers.set(CONTENT_ENCODING, GZIP);
95+
exchange.sendResponseHeaders(status, CHUNKED);
96+
try (OutputStream out = ResponseCompression.gzipStream(exchange.getResponseBody())) {
97+
writer.writeTo(out);
98+
}
99+
return;
100+
}
101+
exchange.sendResponseHeaders(status, Math.max(declared, CHUNKED));
51102
try (OutputStream out = exchange.getResponseBody()) {
52103
writer.writeTo(out);
53104
}
54105
}
55106

107+
/**
108+
* A coded stream has to go out chunked, because the length a {@code Sized} body declares measures
109+
* the uncoded form. A body of unknown length is compressed regardless of the threshold —
110+
* buffering it to find out how big it is would defeat streaming it.
111+
*/
112+
private boolean compressStream(
113+
HttpExchange exchange, Headers headers, int status, String contentType, long declaredLength) {
114+
if (headers.containsKey(CONTENT_ENCODING)
115+
|| !ResponseCompression.isCompressible(contentType)
116+
|| !bodyAllowed(status)) {
117+
return false;
118+
}
119+
addVary(headers);
120+
boolean worthCoding = declaredLength < 0 || declaredLength >= minimumGzipBytes;
121+
return worthCoding && acceptsGzip(exchange);
122+
}
123+
124+
/** The length a handler declared for a body it did not write, or -1 when absent or unreadable. */
125+
private static long declaredLength(Headers headers) {
126+
String declared = headers.getFirst(CONTENT_LENGTH);
127+
if (declared == null) {
128+
return UNKNOWN_LENGTH;
129+
}
130+
try {
131+
return Long.parseLong(declared.trim());
132+
} catch (NumberFormatException e) {
133+
return UNKNOWN_LENGTH;
134+
}
135+
}
136+
56137
private void renderBytes(
57138
HttpExchange exchange, Headers headers, int status, String contentType, Object body)
58139
throws IOException {
@@ -68,12 +149,71 @@ private void renderBytes(
68149
if (!headers.containsKey(CONTENT_TYPE)) {
69150
headers.add(CONTENT_TYPE, effectiveContentType);
70151
}
71-
exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length);
72-
if (bytes.length > 0) {
152+
byte[] payload = maybeCompress(exchange, headers, status, effectiveContentType, bytes);
153+
exchange.sendResponseHeaders(status, payload.length == 0 ? -1 : payload.length);
154+
if (payload.length > 0) {
73155
try (OutputStream out = exchange.getResponseBody()) {
74-
out.write(bytes);
156+
out.write(payload);
157+
}
158+
}
159+
}
160+
161+
/**
162+
* Gzips the body when the client asked for it and the payload is big enough to be worth it. A
163+
* handler that coded the body itself is left alone, and so is a payload that gzip fails to
164+
* shrink.
165+
*/
166+
private byte[] maybeCompress(
167+
HttpExchange exchange, Headers headers, int status, String contentType, byte[] bytes)
168+
throws IOException {
169+
if (headers.containsKey(CONTENT_ENCODING)
170+
|| !ResponseCompression.isCompressible(contentType)
171+
|| !bodyAllowed(status)) {
172+
return bytes;
173+
}
174+
addVary(headers);
175+
if (bytes.length < minimumGzipBytes || !acceptsGzip(exchange)) {
176+
return bytes;
177+
}
178+
byte[] gzipped = ResponseCompression.gzip(bytes);
179+
if (gzipped.length >= bytes.length) {
180+
return bytes;
181+
}
182+
headers.set(CONTENT_ENCODING, GZIP);
183+
return gzipped;
184+
}
185+
186+
/** Statuses that carry no content cannot carry a content coding either. */
187+
private static boolean bodyAllowed(int status) {
188+
return status >= HTTP_OK
189+
&& status != HTTP_NO_CONTENT
190+
&& status != HTTP_RESET
191+
&& status != HTTP_PARTIAL
192+
&& status != HTTP_NOT_MODIFIED;
193+
}
194+
195+
private static boolean acceptsGzip(HttpExchange exchange) {
196+
return AcceptEncodingHeader.acceptsGzip(exchange.getRequestHeaders().getFirst(ACCEPT_ENCODING));
197+
}
198+
199+
/**
200+
* Marks the response as varying by {@code Accept-Encoding} so shared caches keep the coded and
201+
* uncoded forms apart. Announced whenever the body could have been coded, not only when it was,
202+
* and merged into one field line so a client reading a single value sees the whole list.
203+
*/
204+
private static void addVary(Headers headers) {
205+
String existing = headers.getFirst(VARY);
206+
if (existing == null) {
207+
headers.set(VARY, ACCEPT_ENCODING);
208+
return;
209+
}
210+
for (String field : existing.split(",")) {
211+
String trimmed = field.trim();
212+
if ("*".equals(trimmed) || ACCEPT_ENCODING.equalsIgnoreCase(trimmed)) {
213+
return;
75214
}
76215
}
216+
headers.set(VARY, existing + ", " + ACCEPT_ENCODING);
77217
}
78218

79219
private byte[] serialize(Object body, String contentType) {

src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class DispatchHandlerTest {
2929

3030
private static HttpExchange stubExchange() {
3131
HttpExchange exchange = mock(HttpExchange.class);
32+
when(exchange.getRequestHeaders()).thenReturn(new Headers());
3233
when(exchange.getResponseHeaders()).thenReturn(new Headers());
3334
Map<String, Object> attrs = new HashMap<>();
3435
doAnswer(

0 commit comments

Comments
 (0)