| /* |
| * ==================================================================== |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| * ==================================================================== |
| * |
| * This software consists of voluntary contributions made by many |
| * individuals on behalf of the Apache Software Foundation. For more |
| * information on the Apache Software Foundation, please see |
| * <http://www.apache.org/>. |
| * |
| */ |
| |
| package org.apache.hc.client5.http.impl.cache; |
| |
| import java.io.ByteArrayInputStream; |
| import java.io.ByteArrayOutputStream; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.OutputStream; |
| import java.util.Date; |
| import java.util.HashMap; |
| import java.util.Map; |
| |
| import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; |
| import org.apache.hc.client5.http.cache.HttpCacheEntry; |
| import org.apache.hc.client5.http.cache.HttpCacheEntrySerializer; |
| import org.apache.hc.client5.http.cache.HttpCacheStorageEntry; |
| import org.apache.hc.client5.http.cache.Resource; |
| import org.apache.hc.client5.http.cache.ResourceIOException; |
| import org.apache.hc.core5.annotation.Experimental; |
| import org.apache.hc.core5.http.Header; |
| import org.apache.hc.core5.http.ClassicHttpResponse; |
| import org.apache.hc.core5.http.HttpException; |
| import org.apache.hc.core5.http.HttpRequest; |
| import org.apache.hc.core5.http.HttpResponse; |
| import org.apache.hc.core5.http.HttpVersion; |
| import org.apache.hc.core5.http.ProtocolVersion; |
| import org.apache.hc.core5.http.impl.io.AbstractMessageParser; |
| import org.apache.hc.core5.http.impl.io.AbstractMessageWriter; |
| import org.apache.hc.core5.http.impl.io.DefaultHttpResponseParser; |
| import org.apache.hc.core5.http.impl.io.SessionInputBufferImpl; |
| import org.apache.hc.core5.http.impl.io.SessionOutputBufferImpl; |
| import org.apache.hc.core5.http.io.SessionInputBuffer; |
| import org.apache.hc.core5.http.io.SessionOutputBuffer; |
| import org.apache.hc.core5.http.message.BasicHttpRequest; |
| import org.apache.hc.core5.http.message.BasicLineFormatter; |
| import org.apache.hc.core5.http.message.StatusLine; |
| import org.apache.hc.core5.util.CharArrayBuffer; |
| import org.apache.hc.core5.util.TimeValue; |
| |
| /** |
| * Cache serializer and deserializer that uses an HTTP-like format. |
| * |
| * Existing libraries for reading and writing HTTP are used, and metadata is encoded into HTTP |
| * pseudo-headers for storage. |
| */ |
| @Experimental |
| public class HttpByteArrayCacheEntrySerializer implements HttpCacheEntrySerializer<byte[]> { |
| public static final HttpByteArrayCacheEntrySerializer INSTANCE = new HttpByteArrayCacheEntrySerializer(); |
| |
| private static final String SC_CACHE_ENTRY_PREFIX = "hc-"; |
| |
| private static final String SC_HEADER_NAME_STORAGE_KEY = SC_CACHE_ENTRY_PREFIX + "sk"; |
| private static final String SC_HEADER_NAME_RESPONSE_DATE = SC_CACHE_ENTRY_PREFIX + "resp-date"; |
| private static final String SC_HEADER_NAME_REQUEST_DATE = SC_CACHE_ENTRY_PREFIX + "req-date"; |
| private static final String SC_HEADER_NAME_NO_CONTENT = SC_CACHE_ENTRY_PREFIX + "no-content"; |
| private static final String SC_HEADER_NAME_VARIANT_MAP_KEY = SC_CACHE_ENTRY_PREFIX + "varmap-key"; |
| private static final String SC_HEADER_NAME_VARIANT_MAP_VALUE = SC_CACHE_ENTRY_PREFIX + "varmap-val"; |
| |
| private static final String SC_CACHE_ENTRY_PRESERVE_PREFIX = SC_CACHE_ENTRY_PREFIX + "esc-"; |
| |
| private static final int BUFFER_SIZE = 8192; |
| |
| public HttpByteArrayCacheEntrySerializer() { |
| } |
| |
| @Override |
| public byte[] serialize(final HttpCacheStorageEntry httpCacheEntry) throws ResourceIOException { |
| if (httpCacheEntry.getKey() == null) { |
| throw new IllegalStateException("Cannot serialize cache object with null storage key"); |
| } |
| // content doesn't need null-check because it's validated in the HttpCacheStorageEntry constructor |
| |
| // Fake HTTP request, required by response generator |
| // Use request method from httpCacheEntry, but as far as I can tell it will only ever return "GET". |
| final HttpRequest httpRequest = new BasicHttpRequest(httpCacheEntry.getContent().getRequestMethod(), "/"); |
| |
| final CacheValidityPolicy cacheValidityPolicy = new NoAgeCacheValidityPolicy(); |
| final CachedHttpResponseGenerator cachedHttpResponseGenerator = new CachedHttpResponseGenerator(cacheValidityPolicy); |
| |
| final SimpleHttpResponse httpResponse = cachedHttpResponseGenerator.generateResponse(httpRequest, httpCacheEntry.getContent()); |
| |
| try(final ByteArrayOutputStream out = new ByteArrayOutputStream()) { |
| escapeHeaders(httpResponse); |
| addMetadataPseudoHeaders(httpResponse, httpCacheEntry); |
| |
| final byte[] bodyBytes = httpResponse.getBodyBytes(); |
| final int resourceLength; |
| |
| if (bodyBytes == null) { |
| // This means no content, for example a 204 response |
| httpResponse.addHeader(SC_HEADER_NAME_NO_CONTENT, Boolean.TRUE.toString()); |
| resourceLength = 0; |
| } else { |
| resourceLength = bodyBytes.length; |
| } |
| |
| // Use the default, ASCII-only encoder for HTTP protocol and header values. |
| // It's the only thing that's widely used, and it's not worth it to support anything else. |
| final SessionOutputBufferImpl outputBuffer = new SessionOutputBufferImpl(BUFFER_SIZE); |
| final AbstractMessageWriter<SimpleHttpResponse> httpResponseWriter = makeHttpResponseWriter(outputBuffer); |
| httpResponseWriter.write(httpResponse, outputBuffer, out); |
| outputBuffer.flush(out); |
| final byte[] headerBytes = out.toByteArray(); |
| |
| final byte[] bytes = new byte[headerBytes.length + resourceLength]; |
| System.arraycopy(headerBytes, 0, bytes, 0, headerBytes.length); |
| if (resourceLength > 0) { |
| System.arraycopy(bodyBytes, 0, bytes, headerBytes.length, resourceLength); |
| } |
| return bytes; |
| } catch(final IOException|HttpException e) { |
| throw new ResourceIOException("Exception while serializing cache entry", e); |
| } |
| } |
| |
| @Override |
| public HttpCacheStorageEntry deserialize(final byte[] serializedObject) throws ResourceIOException { |
| try (final InputStream in = makeByteArrayInputStream(serializedObject); |
| final ByteArrayOutputStream bytesOut = new ByteArrayOutputStream(serializedObject.length) // this is bigger than necessary but will save us from reallocating |
| ) { |
| final SessionInputBufferImpl inputBuffer = new SessionInputBufferImpl(BUFFER_SIZE); |
| final AbstractMessageParser<ClassicHttpResponse> responseParser = makeHttpResponseParser(); |
| final ClassicHttpResponse response = responseParser.parse(inputBuffer, in); |
| |
| // Extract metadata pseudo-headers |
| final String storageKey = getCachePseudoHeaderAndRemove(response, SC_HEADER_NAME_STORAGE_KEY); |
| final Date requestDate = getCachePseudoHeaderDateAndRemove(response, SC_HEADER_NAME_REQUEST_DATE); |
| final Date responseDate = getCachePseudoHeaderDateAndRemove(response, SC_HEADER_NAME_RESPONSE_DATE); |
| final boolean noBody = getCachePseudoHeaderBooleanAndRemove(response, SC_HEADER_NAME_NO_CONTENT); |
| final Map<String, String> variantMap = getVariantMapPseudoHeadersAndRemove(response); |
| unescapeHeaders(response); |
| |
| final Resource resource; |
| if (noBody) { |
| // This means no content, for example a 204 response |
| resource = null; |
| } else { |
| copyBytes(inputBuffer, in, bytesOut); |
| resource = new HeapResource(bytesOut.toByteArray()); |
| } |
| |
| final HttpCacheEntry httpCacheEntry = new HttpCacheEntry( |
| requestDate, |
| responseDate, |
| response.getCode(), |
| response.getHeaders(), |
| resource, |
| variantMap |
| ); |
| |
| return new HttpCacheStorageEntry(storageKey, httpCacheEntry); |
| } catch (final IOException|HttpException e) { |
| throw new ResourceIOException("Error deserializing cache entry", e); |
| } |
| } |
| |
| /** |
| * Helper method to make a new HTTP response writer. |
| * <p> |
| * Useful to override for testing. |
| * |
| * @param outputBuffer Output buffer to write to |
| * @return HTTP response writer to write to |
| */ |
| protected AbstractMessageWriter<SimpleHttpResponse> makeHttpResponseWriter(final SessionOutputBuffer outputBuffer) { |
| return new SimpleHttpResponseWriter(); |
| } |
| |
| /** |
| * Helper method to make a new ByteArrayInputStream. |
| * <p> |
| * Useful to override for testing. |
| * |
| * @param bytes Bytes to read from the stream |
| * @return Stream to read the bytes from |
| */ |
| protected InputStream makeByteArrayInputStream(final byte[] bytes) { |
| return new ByteArrayInputStream(bytes); |
| } |
| |
| /** |
| * Helper method to make a new HTTP Response parser. |
| * <p> |
| * Useful to override for testing. |
| * |
| * @return HTTP response parser |
| */ |
| protected AbstractMessageParser<ClassicHttpResponse> makeHttpResponseParser() { |
| return new DefaultHttpResponseParser(); |
| } |
| |
| /** |
| * Modify the given response to escape any header names that start with the prefix we use for our own pseudo-headers, |
| * prefixing them with an escape sequence we can use to recover them later. |
| * |
| * @param httpResponse HTTP response object to escape headers in |
| * @see #unescapeHeaders(HttpResponse) for the corresponding un-escaper. |
| */ |
| private static void escapeHeaders(final HttpResponse httpResponse) { |
| final Header[] headers = httpResponse.getHeaders(); |
| for (final Header header : headers) { |
| if (header.getName().startsWith(SC_CACHE_ENTRY_PREFIX)) { |
| httpResponse.removeHeader(header); |
| httpResponse.addHeader(SC_CACHE_ENTRY_PRESERVE_PREFIX + header.getName(), header.getValue()); |
| } |
| } |
| } |
| |
| /** |
| * Modify the given response to remove escaping from any header names we escaped before saving. |
| * |
| * @param httpResponse HTTP response object to un-escape headers in |
| * @see #unescapeHeaders(HttpResponse) for the corresponding escaper |
| */ |
| private void unescapeHeaders(final HttpResponse httpResponse) { |
| final Header[] headers = httpResponse.getHeaders(); |
| for (final Header header : headers) { |
| if (header.getName().startsWith(SC_CACHE_ENTRY_PRESERVE_PREFIX)) { |
| httpResponse.removeHeader(header); |
| httpResponse.addHeader(header.getName().substring(SC_CACHE_ENTRY_PRESERVE_PREFIX.length()), header.getValue()); |
| } |
| } |
| } |
| |
| /** |
| * Modify the given response to add our own cache metadata as pseudo-headers. |
| * |
| * @param httpResponse HTTP response object to add pseudo-headers to |
| */ |
| private void addMetadataPseudoHeaders(final HttpResponse httpResponse, final HttpCacheStorageEntry httpCacheEntry) { |
| httpResponse.addHeader(SC_HEADER_NAME_STORAGE_KEY, httpCacheEntry.getKey()); |
| httpResponse.addHeader(SC_HEADER_NAME_RESPONSE_DATE, Long.toString(httpCacheEntry.getContent().getResponseDate().getTime())); |
| httpResponse.addHeader(SC_HEADER_NAME_REQUEST_DATE, Long.toString(httpCacheEntry.getContent().getRequestDate().getTime())); |
| |
| // Encode these so map entries are stored in a pair of headers, one for key and one for value. |
| // Header keys look like: {Accept-Encoding=gzip} |
| // And header values like: {Accept-Encoding=gzip}https://example.com:1234/foo |
| for (final Map.Entry<String, String> entry : httpCacheEntry.getContent().getVariantMap().entrySet()) { |
| // Headers are ordered |
| httpResponse.addHeader(SC_HEADER_NAME_VARIANT_MAP_KEY, entry.getKey()); |
| httpResponse.addHeader(SC_HEADER_NAME_VARIANT_MAP_VALUE, entry.getValue()); |
| } |
| } |
| |
| /** |
| * Get the string value for a single metadata pseudo-header, and remove it from the response object. |
| * |
| * @param response Response object to get and remove the pseudo-header from |
| * @param name Name of metadata pseudo-header |
| * @return Value for metadata pseudo-header |
| * @throws ResourceIOException if the given pseudo-header is not found |
| */ |
| private static String getCachePseudoHeaderAndRemove(final HttpResponse response, final String name) throws ResourceIOException { |
| final String headerValue = getOptionalCachePseudoHeaderAndRemove(response, name); |
| if (headerValue == null) { |
| throw new ResourceIOException("Expected cache header '" + name + "' not found"); |
| } |
| return headerValue; |
| } |
| |
| /** |
| * Get the string value for a single metadata pseudo-header if it exists, and remove it from the response object. |
| * |
| * @param response Response object to get and remove the pseudo-header from |
| * @param name Name of metadata pseudo-header |
| * @return Value for metadata pseudo-header, or null if it does not exist |
| */ |
| private static String getOptionalCachePseudoHeaderAndRemove(final HttpResponse response, final String name) { |
| final Header header = response.getFirstHeader(name); |
| if (header == null) { |
| return null; |
| } |
| response.removeHeader(header); |
| return header.getValue(); |
| } |
| |
| /** |
| * Get the date value for a single metadata pseudo-header, and remove it from the response object. |
| * |
| * @param response Response object to get and remove the pseudo-header from |
| * @param name Name of metadata pseudo-header |
| * @return Value for metadata pseudo-header |
| * @throws ResourceIOException if the given pseudo-header is not found, or contains invalid data |
| */ |
| private static Date getCachePseudoHeaderDateAndRemove(final HttpResponse response, final String name) throws ResourceIOException{ |
| final String value = getCachePseudoHeaderAndRemove(response, name); |
| response.removeHeaders(name); |
| try { |
| final long timestamp = Long.parseLong(value); |
| return new Date(timestamp); |
| } catch (final NumberFormatException e) { |
| throw new ResourceIOException("Invalid value for header '" + name + "'", e); |
| } |
| } |
| |
| /** |
| * Get the boolean value for a single metadata pseudo-header, and remove it from the response object. |
| * |
| * @param response Response object to get and remove the pseudo-header from |
| * @param name Name of metadata pseudo-header |
| * @return Value for metadata pseudo-header |
| */ |
| private static boolean getCachePseudoHeaderBooleanAndRemove(final ClassicHttpResponse response, final String name) { |
| // parseBoolean does not throw any exceptions, so no try/catch required. |
| return Boolean.parseBoolean(getOptionalCachePseudoHeaderAndRemove(response, name)); |
| } |
| |
| /** |
| * Get the variant map metadata pseudo-header, and remove it from the response object. |
| * |
| * @param response Response object to get and remove the pseudo-header from |
| * @return Extracted variant map |
| * @throws ResourceIOException if the given pseudo-header is not found, or contains invalid data |
| */ |
| private static Map<String, String> getVariantMapPseudoHeadersAndRemove(final HttpResponse response) throws ResourceIOException { |
| final Header[] headers = response.getHeaders(); |
| final Map<String, String> variantMap = new HashMap<>(0); |
| String lastKey = null; |
| for (final Header header : headers) { |
| if (header.getName().equals(SC_HEADER_NAME_VARIANT_MAP_KEY)) { |
| lastKey = header.getValue(); |
| response.removeHeader(header); |
| } else if (header.getName().equals(SC_HEADER_NAME_VARIANT_MAP_VALUE)) { |
| if (lastKey == null) { |
| throw new ResourceIOException("Found mismatched variant map key/value headers"); |
| } |
| variantMap.put(lastKey, header.getValue()); |
| lastKey = null; |
| response.removeHeader(header); |
| } |
| } |
| |
| if (lastKey != null) { |
| throw new ResourceIOException("Found mismatched variant map key/value headers"); |
| } |
| |
| return variantMap; |
| } |
| |
| /** |
| * Copy bytes from the given source buffer and input stream to the given output stream until end-of-file is reached. |
| * |
| * @param srcBuf Buffered input source |
| * @param src Unbuffered input source |
| * @param dest Output destination |
| * @throws IOException if an I/O error occurs |
| */ |
| private static void copyBytes(final SessionInputBuffer srcBuf, final InputStream src, final OutputStream dest) throws IOException { |
| final byte[] buf = new byte[BUFFER_SIZE]; |
| int lastBytesRead; |
| while ((lastBytesRead = srcBuf.read(buf, src)) != -1) { |
| dest.write(buf, 0, lastBytesRead); |
| } |
| } |
| |
| /** |
| * Writer for SimpleHttpResponse. |
| * |
| * Copied from DefaultHttpResponseWriter, but wrapping a SimpleHttpResponse instead of a ClassicHttpResponse |
| */ |
| // Seems like the DefaultHttpResponseWriter should be able to do this, but it doesn't seem to be able to |
| private class SimpleHttpResponseWriter extends AbstractMessageWriter<SimpleHttpResponse> { |
| |
| public SimpleHttpResponseWriter() { |
| super(BasicLineFormatter.INSTANCE); |
| } |
| |
| @Override |
| protected void writeHeadLine( |
| final SimpleHttpResponse message, final CharArrayBuffer lineBuf) { |
| final ProtocolVersion transportVersion = message.getVersion(); |
| BasicLineFormatter.INSTANCE.formatStatusLine(lineBuf, new StatusLine( |
| transportVersion != null ? transportVersion : HttpVersion.HTTP_1_1, |
| message.getCode(), |
| message.getReasonPhrase())); |
| } |
| } |
| |
| /** |
| * Cache validity policy that always returns an age of {@link TimeValue#ZERO_MILLISECONDS}. |
| * |
| * This prevents the Age header from being written to the cache (it does not make sense to cache it), |
| * and is the only thing the policy is used for in this case. |
| */ |
| private static class NoAgeCacheValidityPolicy extends CacheValidityPolicy { |
| @Override |
| public TimeValue getCurrentAge(final HttpCacheEntry entry, final Date now) { |
| return TimeValue.ZERO_MILLISECONDS; |
| } |
| } |
| } |