11package 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+
39import com .retailsvc .http .Response ;
410import com .retailsvc .http .TypeMapper ;
511import com .sun .net .httpserver .Headers ;
1218/** Writes a {@link Response} to an {@link HttpExchange}. */
1319public 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 ) {
0 commit comments