View Javadoc
1   /*
2    * ====================================================================
3    * Licensed to the Apache Software Foundation (ASF) under one
4    * or more contributor license agreements.  See the NOTICE file
5    * distributed with this work for additional information
6    * regarding copyright ownership.  The ASF licenses this file
7    * to you under the Apache License, Version 2.0 (the
8    * "License"); you may not use this file except in compliance
9    * with the License.  You may obtain a copy of the License at
10   *
11   *   http://www.apache.org/licenses/LICENSE-2.0
12   *
13   * Unless required by applicable law or agreed to in writing,
14   * software distributed under the License is distributed on an
15   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16   * KIND, either express or implied.  See the License for the
17   * specific language governing permissions and limitations
18   * under the License.
19   * ====================================================================
20   *
21   * This software consists of voluntary contributions made by many
22   * individuals on behalf of the Apache Software Foundation.  For more
23   * information on the Apache Software Foundation, please see
24   * <http://www.apache.org/>.
25   *
26   */
27  
28  package org.apache.hc.client5.http.impl.cache;
29  
30  import java.io.ByteArrayInputStream;
31  import java.io.ByteArrayOutputStream;
32  import java.io.IOException;
33  import java.io.InputStream;
34  import java.io.OutputStream;
35  import java.time.Instant;
36  import java.util.HashMap;
37  import java.util.Map;
38  
39  import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
40  import org.apache.hc.client5.http.cache.HttpCacheEntry;
41  import org.apache.hc.client5.http.cache.HttpCacheEntrySerializer;
42  import org.apache.hc.client5.http.cache.HttpCacheStorageEntry;
43  import org.apache.hc.client5.http.cache.Resource;
44  import org.apache.hc.client5.http.cache.ResourceIOException;
45  import org.apache.hc.core5.annotation.Experimental;
46  import org.apache.hc.core5.http.Header;
47  import org.apache.hc.core5.http.ClassicHttpResponse;
48  import org.apache.hc.core5.http.HttpException;
49  import org.apache.hc.core5.http.HttpRequest;
50  import org.apache.hc.core5.http.HttpResponse;
51  import org.apache.hc.core5.http.HttpVersion;
52  import org.apache.hc.core5.http.ProtocolVersion;
53  import org.apache.hc.core5.http.impl.io.AbstractMessageParser;
54  import org.apache.hc.core5.http.impl.io.AbstractMessageWriter;
55  import org.apache.hc.core5.http.impl.io.DefaultHttpResponseParser;
56  import org.apache.hc.core5.http.impl.io.SessionInputBufferImpl;
57  import org.apache.hc.core5.http.impl.io.SessionOutputBufferImpl;
58  import org.apache.hc.core5.http.io.SessionInputBuffer;
59  import org.apache.hc.core5.http.io.SessionOutputBuffer;
60  import org.apache.hc.core5.http.message.BasicHttpRequest;
61  import org.apache.hc.core5.http.message.BasicLineFormatter;
62  import org.apache.hc.core5.http.message.StatusLine;
63  import org.apache.hc.core5.util.CharArrayBuffer;
64  import org.apache.hc.core5.util.TimeValue;
65  
66  /**
67   * Cache serializer and deserializer that uses an HTTP-like format.
68   *
69   * Existing libraries for reading and writing HTTP are used, and metadata is encoded into HTTP
70   * pseudo-headers for storage.
71   */
72  @Experimental
73  public class HttpByteArrayCacheEntrySerializer implements HttpCacheEntrySerializer<byte[]> {
74      public static final HttpByteArrayCacheEntrySerializer INSTANCE = new HttpByteArrayCacheEntrySerializer();
75  
76      private static final String SC_CACHE_ENTRY_PREFIX = "hc-";
77  
78      private static final String SC_HEADER_NAME_STORAGE_KEY = SC_CACHE_ENTRY_PREFIX + "sk";
79      private static final String SC_HEADER_NAME_RESPONSE_DATE = SC_CACHE_ENTRY_PREFIX + "resp-date";
80      private static final String SC_HEADER_NAME_REQUEST_DATE = SC_CACHE_ENTRY_PREFIX + "req-date";
81      private static final String SC_HEADER_NAME_NO_CONTENT = SC_CACHE_ENTRY_PREFIX + "no-content";
82      private static final String SC_HEADER_NAME_VARIANT_MAP_KEY = SC_CACHE_ENTRY_PREFIX + "varmap-key";
83      private static final String SC_HEADER_NAME_VARIANT_MAP_VALUE = SC_CACHE_ENTRY_PREFIX + "varmap-val";
84  
85      private static final String SC_CACHE_ENTRY_PRESERVE_PREFIX = SC_CACHE_ENTRY_PREFIX + "esc-";
86  
87      private static final int BUFFER_SIZE = 8192;
88  
89      public HttpByteArrayCacheEntrySerializer() {
90      }
91  
92      @Override
93      public byte[] serialize(final HttpCacheStorageEntry httpCacheEntry) throws ResourceIOException {
94          if (httpCacheEntry.getKey() == null) {
95              throw new IllegalStateException("Cannot serialize cache object with null storage key");
96          }
97          // content doesn't need null-check because it's validated in the HttpCacheStorageEntry constructor
98  
99          // Fake HTTP request, required by response generator
100         // Use request method from httpCacheEntry, but as far as I can tell it will only ever return "GET".
101         final HttpRequest httpRequest = new BasicHttpRequest(httpCacheEntry.getContent().getRequestMethod(), "/");
102 
103         final CacheValidityPolicy cacheValidityPolicy = new NoAgeCacheValidityPolicy();
104         final CachedHttpResponseGenerator cachedHttpResponseGenerator = new CachedHttpResponseGenerator(cacheValidityPolicy);
105 
106         final SimpleHttpResponse httpResponse = cachedHttpResponseGenerator.generateResponse(httpRequest, httpCacheEntry.getContent());
107 
108         try(final ByteArrayOutputStream out = new ByteArrayOutputStream()) {
109             escapeHeaders(httpResponse);
110             addMetadataPseudoHeaders(httpResponse, httpCacheEntry);
111 
112             final byte[] bodyBytes = httpResponse.getBodyBytes();
113             final int resourceLength;
114 
115             if (bodyBytes == null) {
116                 // This means no content, for example a 204 response
117                 httpResponse.addHeader(SC_HEADER_NAME_NO_CONTENT, Boolean.TRUE.toString());
118                 resourceLength = 0;
119             } else {
120                 resourceLength = bodyBytes.length;
121             }
122 
123             // Use the default, ASCII-only encoder for HTTP protocol and header values.
124             // It's the only thing that's widely used, and it's not worth it to support anything else.
125             final SessionOutputBufferImpl outputBuffer = new SessionOutputBufferImpl(BUFFER_SIZE);
126             final AbstractMessageWriter<SimpleHttpResponse> httpResponseWriter = makeHttpResponseWriter(outputBuffer);
127             httpResponseWriter.write(httpResponse, outputBuffer, out);
128             outputBuffer.flush(out);
129             final byte[] headerBytes = out.toByteArray();
130 
131             final byte[] bytes = new byte[headerBytes.length + resourceLength];
132             System.arraycopy(headerBytes, 0, bytes, 0, headerBytes.length);
133             if (resourceLength > 0) {
134                 System.arraycopy(bodyBytes, 0, bytes, headerBytes.length, resourceLength);
135             }
136             return bytes;
137         } catch(final IOException|HttpException e) {
138             throw new ResourceIOException("Exception while serializing cache entry", e);
139         }
140     }
141 
142     @Override
143     public HttpCacheStorageEntry deserialize(final byte[] serializedObject) throws ResourceIOException {
144         try (final InputStream in = makeByteArrayInputStream(serializedObject);
145              final ByteArrayOutputStream bytesOut = new ByteArrayOutputStream(serializedObject.length) // this is bigger than necessary but will save us from reallocating
146         ) {
147             final SessionInputBufferImpl inputBuffer = new SessionInputBufferImpl(BUFFER_SIZE);
148             final AbstractMessageParser<ClassicHttpResponse> responseParser = makeHttpResponseParser();
149             final ClassicHttpResponse response = responseParser.parse(inputBuffer, in);
150 
151             // Extract metadata pseudo-headers
152             final String storageKey = getCachePseudoHeaderAndRemove(response, SC_HEADER_NAME_STORAGE_KEY);
153             final Instant requestDate = getCachePseudoHeaderDateAndRemove(response, SC_HEADER_NAME_REQUEST_DATE);
154             final Instant responseDate = getCachePseudoHeaderDateAndRemove(response, SC_HEADER_NAME_RESPONSE_DATE);
155             final boolean noBody = getCachePseudoHeaderBooleanAndRemove(response, SC_HEADER_NAME_NO_CONTENT);
156             final Map<String, String> variantMap = getVariantMapPseudoHeadersAndRemove(response);
157             unescapeHeaders(response);
158 
159             final Resource resource;
160             if (noBody) {
161                 // This means no content, for example a 204 response
162                 resource = null;
163             } else {
164                 copyBytes(inputBuffer, in, bytesOut);
165                 resource = new HeapResource(bytesOut.toByteArray());
166             }
167 
168             final HttpCacheEntry httpCacheEntry = new HttpCacheEntry(
169                     requestDate,
170                     responseDate,
171                     response.getCode(),
172                     response.getHeaders(),
173                     resource,
174                     variantMap
175             );
176 
177             return new HttpCacheStorageEntry(storageKey, httpCacheEntry);
178         } catch (final IOException|HttpException e) {
179             throw new ResourceIOException("Error deserializing cache entry", e);
180         }
181     }
182 
183     /**
184      * Helper method to make a new HTTP response writer.
185      * <p>
186      * Useful to override for testing.
187      *
188      * @param outputBuffer Output buffer to write to
189      * @return HTTP response writer to write to
190      */
191     protected AbstractMessageWriter<SimpleHttpResponse> makeHttpResponseWriter(final SessionOutputBuffer outputBuffer) {
192         return new SimpleHttpResponseWriter();
193     }
194 
195     /**
196      * Helper method to make a new ByteArrayInputStream.
197      * <p>
198      * Useful to override for testing.
199      *
200      * @param bytes Bytes to read from the stream
201      * @return Stream to read the bytes from
202      */
203     protected InputStream makeByteArrayInputStream(final byte[] bytes) {
204         return new ByteArrayInputStream(bytes);
205     }
206 
207     /**
208      * Helper method to make a new HTTP Response parser.
209      * <p>
210      * Useful to override for testing.
211      *
212      * @return HTTP response parser
213      */
214     protected AbstractMessageParser<ClassicHttpResponse> makeHttpResponseParser() {
215         return new DefaultHttpResponseParser();
216     }
217 
218     /**
219      * Modify the given response to escape any header names that start with the prefix we use for our own pseudo-headers,
220      * prefixing them with an escape sequence we can use to recover them later.
221      *
222      * @param httpResponse HTTP response object to escape headers in
223      * @see #unescapeHeaders(HttpResponse) for the corresponding un-escaper.
224      */
225     private static void escapeHeaders(final HttpResponse httpResponse) {
226         final Header[] headers = httpResponse.getHeaders();
227         for (final Header header : headers) {
228             if (header.getName().startsWith(SC_CACHE_ENTRY_PREFIX)) {
229                 httpResponse.removeHeader(header);
230                 httpResponse.addHeader(SC_CACHE_ENTRY_PRESERVE_PREFIX + header.getName(), header.getValue());
231             }
232         }
233     }
234 
235     /**
236      * Modify the given response to remove escaping from any header names we escaped before saving.
237      *
238      * @param httpResponse HTTP response object to un-escape headers in
239      * @see #unescapeHeaders(HttpResponse) for the corresponding escaper
240      */
241     private void unescapeHeaders(final HttpResponse httpResponse) {
242         final Header[] headers = httpResponse.getHeaders();
243         for (final Header header : headers) {
244             if (header.getName().startsWith(SC_CACHE_ENTRY_PRESERVE_PREFIX)) {
245                 httpResponse.removeHeader(header);
246                 httpResponse.addHeader(header.getName().substring(SC_CACHE_ENTRY_PRESERVE_PREFIX.length()), header.getValue());
247             }
248         }
249     }
250 
251     /**
252      * Modify the given response to add our own cache metadata as pseudo-headers.
253      *
254      * @param httpResponse HTTP response object to add pseudo-headers to
255      */
256     private void addMetadataPseudoHeaders(final HttpResponse httpResponse, final HttpCacheStorageEntry httpCacheEntry) {
257         httpResponse.addHeader(SC_HEADER_NAME_STORAGE_KEY, httpCacheEntry.getKey());
258         httpResponse.addHeader(SC_HEADER_NAME_RESPONSE_DATE, Long.toString(httpCacheEntry.getContent().getResponseInstant().toEpochMilli()));
259         httpResponse.addHeader(SC_HEADER_NAME_REQUEST_DATE, Long.toString(httpCacheEntry.getContent().getRequestInstant().toEpochMilli()));
260 
261         // Encode these so map entries are stored in a pair of headers, one for key and one for value.
262         // Header keys look like: {Accept-Encoding=gzip}
263         // And header values like: {Accept-Encoding=gzip}https://example.com:1234/foo
264         for (final Map.Entry<String, String> entry : httpCacheEntry.getContent().getVariantMap().entrySet()) {
265             // Headers are ordered
266             httpResponse.addHeader(SC_HEADER_NAME_VARIANT_MAP_KEY, entry.getKey());
267             httpResponse.addHeader(SC_HEADER_NAME_VARIANT_MAP_VALUE, entry.getValue());
268         }
269     }
270 
271     /**
272      * Get the string value for a single metadata pseudo-header, and remove it from the response object.
273      *
274      * @param response Response object to get and remove the pseudo-header from
275      * @param name     Name of metadata pseudo-header
276      * @return Value for metadata pseudo-header
277      * @throws ResourceIOException if the given pseudo-header is not found
278      */
279     private static String getCachePseudoHeaderAndRemove(final HttpResponse response, final String name) throws ResourceIOException {
280         final String headerValue = getOptionalCachePseudoHeaderAndRemove(response, name);
281         if (headerValue == null) {
282             throw new ResourceIOException("Expected cache header '" + name + "' not found");
283         }
284         return headerValue;
285     }
286 
287     /**
288      * Get the string value for a single metadata pseudo-header if it exists, and remove it from the response object.
289      *
290      * @param response Response object to get and remove the pseudo-header from
291      * @param name     Name of metadata pseudo-header
292      * @return Value for metadata pseudo-header, or null if it does not exist
293      */
294     private static String getOptionalCachePseudoHeaderAndRemove(final HttpResponse response, final String name) {
295         final Header header = response.getFirstHeader(name);
296         if (header == null) {
297             return null;
298         }
299         response.removeHeader(header);
300         return header.getValue();
301     }
302 
303     /**
304      * Get the date value for a single metadata pseudo-header, and remove it from the response object.
305      *
306      * @param response Response object to get and remove the pseudo-header from
307      * @param name     Name of metadata pseudo-header
308      * @return Value for metadata pseudo-header
309      * @throws ResourceIOException if the given pseudo-header is not found, or contains invalid data
310      */
311     private static Instant getCachePseudoHeaderDateAndRemove(final HttpResponse response, final String name) throws ResourceIOException{
312         final String value = getCachePseudoHeaderAndRemove(response, name);
313         response.removeHeaders(name);
314         try {
315             final long timestamp = Long.parseLong(value);
316             return Instant.ofEpochMilli(timestamp);
317         } catch (final NumberFormatException e) {
318             throw new ResourceIOException("Invalid value for header '" + name + "'", e);
319         }
320     }
321 
322     /**
323      * Get the boolean value for a single metadata pseudo-header, and remove it from the response object.
324      *
325      * @param response Response object to get and remove the pseudo-header from
326      * @param name     Name of metadata pseudo-header
327      * @return Value for metadata pseudo-header
328      */
329     private static boolean getCachePseudoHeaderBooleanAndRemove(final ClassicHttpResponse response, final String name) {
330         // parseBoolean does not throw any exceptions, so no try/catch required.
331         return Boolean.parseBoolean(getOptionalCachePseudoHeaderAndRemove(response, name));
332     }
333 
334     /**
335      * Get the variant map metadata pseudo-header, and remove it from the response object.
336      *
337      * @param response Response object to get and remove the pseudo-header from
338      * @return Extracted variant map
339      * @throws ResourceIOException if the given pseudo-header is not found, or contains invalid data
340      */
341     private static Map<String, String> getVariantMapPseudoHeadersAndRemove(final HttpResponse response) throws ResourceIOException {
342         final Header[] headers = response.getHeaders();
343         final Map<String, String> variantMap = new HashMap<>(0);
344         String lastKey = null;
345         for (final Header header : headers) {
346             if (header.getName().equals(SC_HEADER_NAME_VARIANT_MAP_KEY)) {
347                 lastKey = header.getValue();
348                 response.removeHeader(header);
349             } else if (header.getName().equals(SC_HEADER_NAME_VARIANT_MAP_VALUE)) {
350                 if (lastKey == null) {
351                     throw new ResourceIOException("Found mismatched variant map key/value headers");
352                 }
353                 variantMap.put(lastKey, header.getValue());
354                 lastKey = null;
355                 response.removeHeader(header);
356             }
357         }
358 
359         if (lastKey != null) {
360             throw new ResourceIOException("Found mismatched variant map key/value headers");
361         }
362 
363         return variantMap;
364     }
365 
366     /**
367      * Copy bytes from the given source buffer and input stream to the given output stream until end-of-file is reached.
368      *
369      * @param srcBuf Buffered input source
370      * @param src Unbuffered input source
371      * @param dest Output destination
372      * @throws IOException if an I/O error occurs
373      */
374     private static void copyBytes(final SessionInputBuffer srcBuf, final InputStream src, final OutputStream dest) throws IOException {
375         final byte[] buf = new byte[BUFFER_SIZE];
376         int lastBytesRead;
377         while ((lastBytesRead = srcBuf.read(buf, src)) != -1) {
378             dest.write(buf, 0, lastBytesRead);
379         }
380     }
381 
382     /**
383      * Writer for SimpleHttpResponse.
384      *
385      * Copied from DefaultHttpResponseWriter, but wrapping a SimpleHttpResponse instead of a ClassicHttpResponse
386      */
387     // Seems like the DefaultHttpResponseWriter should be able to do this, but it doesn't seem to be able to
388     private class SimpleHttpResponseWriter extends AbstractMessageWriter<SimpleHttpResponse> {
389 
390         public SimpleHttpResponseWriter() {
391             super(BasicLineFormatter.INSTANCE);
392         }
393 
394         @Override
395         protected void writeHeadLine(
396                 final SimpleHttpResponse message, final CharArrayBuffer lineBuf) {
397             final ProtocolVersion transportVersion = message.getVersion();
398             BasicLineFormatter.INSTANCE.formatStatusLine(lineBuf, new StatusLine(
399                     transportVersion != null ? transportVersion : HttpVersion.HTTP_1_1,
400                     message.getCode(),
401                     message.getReasonPhrase()));
402         }
403     }
404 
405     /**
406      * Cache validity policy that always returns an age of {@link TimeValue#ZERO_MILLISECONDS}.
407      *
408      * This prevents the Age header from being written to the cache (it does not make sense to cache it),
409      * and is the only thing the policy is used for in this case.
410      */
411     private static class NoAgeCacheValidityPolicy extends CacheValidityPolicy {
412         @Override
413         public TimeValue getCurrentAge(final HttpCacheEntry entry, final Instant now) {
414             return TimeValue.ZERO_MILLISECONDS;
415         }
416     }
417 }