HTTPCORE-615: Implement HTTP-based cache serializer-deserializer. (#192)

HTTPCORE-615: Implement HTTP-based cache serializer-deserializer.
This commit is contained in:
Scott Gifford 2019-12-30 04:57:58 -05:00 committed by Oleg Kalnichevski
parent 40173ca071
commit f765a81b31
15 changed files with 1226 additions and 0 deletions

1
.gitattributes vendored
View File

@ -21,3 +21,4 @@
*.html text diff=html
*.css text
*.js text
*.serialized binary

View File

@ -0,0 +1,409 @@
/*
* ====================================================================
* 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;
/**
* 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.
*/
protected AbstractMessageWriter<SimpleHttpResponse> makeHttpResponseWriter(final SessionOutputBuffer outputBuffer) {
return new SimpleHttpResponseWriter();
}
/**
* Helper method to make a new ByteArrayInputStream.
* <p>
* Useful to override for testing.
*/
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.
*/
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 0.
*
* 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 class NoAgeCacheValidityPolicy extends CacheValidityPolicy {
@Override
public long getCurrentAgeSecs(final HttpCacheEntry entry, final Date now) {
return 0L;
}
}
}

View File

@ -0,0 +1,342 @@
/*
* ====================================================================
* 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.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Date;
import java.util.Map;
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.http.Header;
import org.apache.hc.core5.http.message.BasicHeader;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.fail;
class HttpByteArrayCacheEntrySerializerTestUtils {
private final static String TEST_RESOURCE_DIR = "src/test/resources/";
static final String TEST_STORAGE_KEY = "xyzzy";
/**
* Template for incrementally building a new HttpCacheStorageEntry test object, starting from defaults.
*/
static class HttpCacheStorageEntryTestTemplate {
Resource resource;
Date requestDate;
Date responseDate;
int responseCode;
Header[] responseHeaders;
Map<String, String> variantMap;
String storageKey;
/**
* Return a new HttpCacheStorageEntryTestTemplate instance with all default values.
*
* @return new HttpCacheStorageEntryTestTemplate instance
*/
static HttpCacheStorageEntryTestTemplate makeDefault() {
return new HttpCacheStorageEntryTestTemplate(DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE);
}
/**
* Convert this template to a HttpCacheStorageEntry object.
* @return HttpCacheStorageEntry object
*/
HttpCacheStorageEntry toEntry() {
return new HttpCacheStorageEntry(storageKey,
new HttpCacheEntry(
requestDate,
responseDate,
responseCode,
responseHeaders,
resource,
variantMap));
}
/**
* Create a new template with all null values.
*/
private HttpCacheStorageEntryTestTemplate() {
}
/**
* Create a new template values copied from the given template
*
* @param src Template to copy values from
*/
private HttpCacheStorageEntryTestTemplate(final HttpCacheStorageEntryTestTemplate src) {
this.resource = src.resource;
this.requestDate = src.requestDate;
this.responseDate = src.responseDate;
this.responseCode = src.responseCode;
this.responseHeaders = src.responseHeaders;
this.variantMap = src.variantMap;
this.storageKey = src.storageKey;
}
}
/**
* Template with all default values.
*
* Used by HttpCacheStorageEntryTestTemplate#makeDefault()
*/
private static final HttpCacheStorageEntryTestTemplate DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE = new HttpCacheStorageEntryTestTemplate();
static {
DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE.resource = new HeapResource("Hello World".getBytes(StandardCharsets.UTF_8));
DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE.requestDate = new Date(165214800000L);
DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE.responseDate = new Date(2611108800000L);
DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE.responseCode = 200;
DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE.responseHeaders = new Header[]{
new BasicHeader("Content-type", "text/html"),
new BasicHeader("Cache-control", "public, max-age=31536000"),
};
DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE.variantMap = Collections.emptyMap();
DEFAULT_HTTP_CACHE_STORAGE_ENTRY_TEST_TEMPLATE.storageKey = TEST_STORAGE_KEY;
}
/**
* Test serializing and deserializing the given object with the given factory.
* <p>
* Compares fields to ensure the deserialized object is equivalent to the original object.
*
* @param serializer Factory for creating serializers
* @param httpCacheStorageEntry Original object to serialize and test against
* @throws Exception if anything goes wrong
*/
static void testWithCache(final HttpCacheEntrySerializer<byte[]> serializer, final HttpCacheStorageEntry httpCacheStorageEntry) throws Exception {
final byte[] testBytes = serializer.serialize(httpCacheStorageEntry);
verifyHttpCacheEntryFromBytes(serializer, httpCacheStorageEntry, testBytes);
}
/**
* Verify that the given bytes deserialize to the given storage key and an equivalent cache entry.
*
* @param serializer Deserializer
* @param httpCacheStorageEntry Cache entry to verify
* @param testBytes Bytes to deserialize
* @throws Exception if anything goes wrong
*/
static void verifyHttpCacheEntryFromBytes(final HttpCacheEntrySerializer<byte[]> serializer, final HttpCacheStorageEntry httpCacheStorageEntry, final byte[] testBytes) throws Exception {
final HttpCacheStorageEntry testEntry = httpCacheStorageEntryFromBytes(serializer, testBytes);
assertCacheEntriesEqual(httpCacheStorageEntry, testEntry);
}
/**
* Verify that the given test file deserializes to a cache entry equivalent to the one given.
*
* @param serializer Deserializer
* @param httpCacheStorageEntry Cache entry to verify
* @param testFileName Name of test file to deserialize
* @param reserializeFiles If true, test files will be regenerated and saved to disk
* @throws Exception if anything goes wrong
*/
static void verifyHttpCacheEntryFromTestFile(final HttpCacheEntrySerializer<byte[]> serializer,
final HttpCacheStorageEntry httpCacheStorageEntry,
final String testFileName,
final boolean reserializeFiles) throws Exception {
if (reserializeFiles) {
final File toFile = makeTestFileObject(testFileName);
saveEntryToFile(serializer, httpCacheStorageEntry, toFile);
}
final byte[] bytes = readTestFileBytes(testFileName);
verifyHttpCacheEntryFromBytes(serializer, httpCacheStorageEntry, bytes);
}
/**
* Get the bytes of the given test file.
*
* @param testFileName Name of test file to get bytes from
* @return Bytes from the given test file
* @throws Exception if anything goes wrong
*/
static byte[] readTestFileBytes(final String testFileName) throws Exception {
final File testFile = makeTestFileObject(testFileName);
try(final FileInputStream testStream = new FileInputStream(testFile)) {
return readFullyStrict(testStream, testFile.length());
}
}
/**
* Create a new cache object from the given bytes.
*
* @param serializer Deserializer
* @param testBytes Bytes to deserialize
* @return Deserialized object
*/
static HttpCacheStorageEntry httpCacheStorageEntryFromBytes(final HttpCacheEntrySerializer<byte[]> serializer, final byte[] testBytes) throws ResourceIOException {
return serializer.deserialize(testBytes);
}
/**
* Assert that the given objects are equivalent
*
* @param expected Expected cache entry object
* @param actual Actual cache entry object
* @throws Exception if anything goes wrong
*/
static void assertCacheEntriesEqual(final HttpCacheStorageEntry expected, final HttpCacheStorageEntry actual) throws Exception {
assertEquals(expected.getKey(), actual.getKey());
final HttpCacheEntry expectedContent = expected.getContent();
final HttpCacheEntry actualContent = actual.getContent();
assertEquals(expectedContent.getRequestDate(), actualContent.getRequestDate());
assertEquals(expectedContent.getResponseDate(), actualContent.getResponseDate());
assertEquals(expectedContent.getStatus(), actualContent.getStatus());
assertArrayEquals(expectedContent.getVariantMap().keySet().toArray(), actualContent.getVariantMap().keySet().toArray());
for (final String key : expectedContent.getVariantMap().keySet()) {
assertEquals("Expected same variantMap values for key '" + key + "'",
expectedContent.getVariantMap().get(key), actualContent.getVariantMap().get(key));
}
// Verify that the same headers are present on the expected and actual content.
for(final Header expectedHeader: expectedContent.getHeaders()) {
final Header actualHeader = actualContent.getFirstHeader(expectedHeader.getName());
if (actualHeader == null) {
if (expectedHeader.getName().equalsIgnoreCase("content-length")) {
// This header is added by the cache implementation, and can be safely ignored
} else {
fail("Expected header " + expectedHeader.getName() + " was not found");
}
} else {
assertEquals(expectedHeader.getName(), actualHeader.getName());
assertEquals(expectedHeader.getValue(), actualHeader.getValue());
}
}
if (expectedContent.getResource() == null) {
assertNull("Expected null resource", actualContent.getResource());
} else {
final byte[] expectedBytes = readFullyStrict(
expectedContent.getResource().getInputStream(),
(int) expectedContent.getResource().length()
);
final byte[] actualBytes = readFullyStrict(
actualContent.getResource().getInputStream(),
(int) actualContent.getResource().length()
);
assertArrayEquals(expectedBytes, actualBytes);
}
}
/**
* Get a File object for the given test file.
*
* @param testFileName Name of test file
* @return File for this test file
*/
static File makeTestFileObject(final String testFileName) {
return new File(TEST_RESOURCE_DIR + testFileName);
}
/**
* Save the given cache entry serialized to the given file.
*
* @param serializer Serializer
* @param httpCacheStorageEntry Cache entry to serialize and save
* @param outFile Output file to write to
* @throws Exception if anything goes wrong
*/
static void saveEntryToFile(final HttpCacheEntrySerializer<byte[]> serializer, final HttpCacheStorageEntry httpCacheStorageEntry, final File outFile) throws Exception {
final byte[] bytes = serializer.serialize(httpCacheStorageEntry);
OutputStream out = null;
try {
out = new FileOutputStream(outFile);
out.write(bytes);
} finally {
if (out != null) {
out.close();
}
}
}
/**
* Copy bytes from the given input stream to the given destination buffer until the buffer is full,
* or end-of-file is reached, and return the number of bytes read.
*
* @param src Input stream to read from
* @param dest Output buffer to write to
* @return Number of bytes read
* @throws IOException if an I/O error occurs
*/
private static int readFully(final InputStream src, final byte[] dest) throws IOException {
final int destPos = 0;
final int length = dest.length;
int totalBytesRead = 0;
int lastBytesRead;
while (totalBytesRead < length && (lastBytesRead = src.read(dest, destPos + totalBytesRead, length - totalBytesRead)) != -1) {
totalBytesRead += lastBytesRead;
}
return totalBytesRead;
}
/**
* Copy bytes from the given input stream to a new buffer until the given length is reached,
* and returns the new buffer. If end-of-file is reached first, an IOException is thrown
*
* @param src Input stream to read from
* @param length Maximum bytes to read
* @return All bytes from file
* @throws IOException if an I/O error occurs or end-of-file is reached before the requested
* number of bytes have been read
*/
static byte[] readFullyStrict(final InputStream src, final long length) throws IOException {
if (length > Integer.MAX_VALUE) {
throw new IllegalArgumentException(String.format("Length %d is too large to fit in an array", length));
}
final int intLength = (int) length;
final byte[] dest = new byte[intLength];
final int bytesRead = readFully(src, dest);
if (bytesRead == intLength) {
return dest;
} else {
throw new IOException(String.format("Expected to read %d bytes but only got %d", intLength, bytesRead));
}
}
}

View File

@ -0,0 +1,397 @@
/*
* ====================================================================
* 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.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;
import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
import org.apache.hc.client5.http.cache.HttpCacheEntrySerializer;
import org.apache.hc.client5.http.cache.HttpCacheStorageEntry;
import org.apache.hc.client5.http.cache.ResourceIOException;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpException;
import org.apache.hc.core5.http.impl.io.AbstractMessageParser;
import org.apache.hc.core5.http.impl.io.AbstractMessageWriter;
import org.apache.hc.core5.http.io.SessionInputBuffer;
import org.apache.hc.core5.http.io.SessionOutputBuffer;
import org.apache.hc.core5.http.message.BasicHeader;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import static org.apache.hc.client5.http.impl.cache.HttpByteArrayCacheEntrySerializerTestUtils.makeTestFileObject;
import static org.apache.hc.client5.http.impl.cache.HttpByteArrayCacheEntrySerializerTestUtils.httpCacheStorageEntryFromBytes;
import static org.apache.hc.client5.http.impl.cache.HttpByteArrayCacheEntrySerializerTestUtils.readTestFileBytes;
import static org.apache.hc.client5.http.impl.cache.HttpByteArrayCacheEntrySerializerTestUtils.testWithCache;
import static org.apache.hc.client5.http.impl.cache.HttpByteArrayCacheEntrySerializerTestUtils.verifyHttpCacheEntryFromTestFile;
import static org.apache.hc.client5.http.impl.cache.HttpByteArrayCacheEntrySerializerTestUtils.HttpCacheStorageEntryTestTemplate;
public class TestHttpByteArrayCacheEntrySerializer {
private static final String SERIALIAZED_EXTENSION = ".httpbytes.serialized";
private static final String FILE_TEST_SERIALIZED_NAME = "ApacheLogo" + SERIALIAZED_EXTENSION;
private static final String SIMPLE_OBJECT_SERIALIZED_NAME = "simpleObject" + SERIALIAZED_EXTENSION;
private static final String VARIANTMAP_TEST_SERIALIZED_NAME = "variantMap" + SERIALIAZED_EXTENSION;
private static final String ESCAPED_HEADER_TEST_SERIALIZED_NAME = "escapedHeader" + SERIALIAZED_EXTENSION;
private static final String NO_BODY_TEST_SERIALIZED_NAME = "noBody" + SERIALIAZED_EXTENSION;
private static final String MISSING_HEADER_TEST_SERIALIZED_NAME = "missingHeader" + SERIALIAZED_EXTENSION;
private static final String INVALID_HEADER_TEST_SERIALIZED_NAME = "invalidHeader" + SERIALIAZED_EXTENSION;
private static final String VARIANTMAP_MISSING_KEY_TEST_SERIALIZED_NAME = "variantMapMissingKey" + SERIALIAZED_EXTENSION;
private static final String VARIANTMAP_MISSING_VALUE_TEST_SERIALIZED_NAME = "variantMapMissingValue" + SERIALIAZED_EXTENSION;
private static final String TEST_CONTENT_FILE_NAME = "ApacheLogo.png";
private HttpCacheEntrySerializer<byte[]> serializer;
// Manually set this to true to re-generate all of the serialized files
private final boolean reserializeFiles = false;
@Before
public void before() {
serializer = HttpByteArrayCacheEntrySerializer.INSTANCE;
}
/**
* Serialize and deserialize a simple object with a tiny body.
*
* @throws Exception if anything goes wrong
*/
@Test
public void simpleObjectTest() throws Exception {
final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
testWithCache(serializer, testEntry);
}
/**
* Serialize and deserialize a larger object with a binary file for a body.
*
* @throws Exception if anything goes wrong
*/
@Test
public void fileObjectTest() throws Exception {
final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
cacheObjectValues.resource = new FileResource(makeTestFileObject(TEST_CONTENT_FILE_NAME));
final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
testWithCache(serializer, testEntry);
}
/**
* Serialize and deserialize a cache entry with no headers.
*
* @throws Exception if anything goes wrong
*/
@Test
public void noHeadersTest() throws Exception {
final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
cacheObjectValues.responseHeaders = new Header[0];
final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
testWithCache(serializer, testEntry);
}
/**
* Serialize and deserialize a cache entry with an empty body.
*
* @throws Exception if anything goes wrong
*/
@Test
public void emptyBodyTest() throws Exception {
final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
cacheObjectValues.resource = new HeapResource(new byte[0]);
final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
testWithCache(serializer, testEntry);
}
/**
* Serialize and deserialize a cache entry with no body.
*
* @throws Exception if anything goes wrong
*/
@Test
public void noBodyTest() throws Exception {
final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
cacheObjectValues.resource = null;
cacheObjectValues.responseCode = 204;
final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
testWithCache(serializer, testEntry);
}
/**
* Serialize and deserialize a cache entry with a variant map.
*
* @throws Exception if anything goes wrong
*/
@Test
public void testSimpleVariantMap() throws Exception {
final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
final Map<String, String> variantMap = new HashMap<>();
variantMap.put("{Accept-Encoding=gzip}","{Accept-Encoding=gzip}https://example.com:1234/foo");
variantMap.put("{Accept-Encoding=compress}","{Accept-Encoding=compress}https://example.com:1234/foo");
cacheObjectValues.variantMap = variantMap;
final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
testWithCache(serializer, testEntry);
}
/**
* Ensures that if the server uses our reserved header names we don't mix them up with our own pseudo-headers.
*
* @throws Exception if anything goes wrong
*/
@Test
public void testEscapedHeaders() throws Exception {
final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
cacheObjectValues.responseHeaders = new Header[] {
new BasicHeader("hc-test-1", "hc-test-1-value"),
new BasicHeader("hc-sk", "hc-sk-value"),
new BasicHeader("hc-resp-date", "hc-resp-date-value"),
new BasicHeader("hc-req-date-date", "hc-req-date-value"),
new BasicHeader("hc-varmap-key", "hc-varmap-key-value"),
new BasicHeader("hc-varmap-val", "hc-varmap-val-value"),
};
final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
testWithCache(serializer, testEntry);
}
/**
* Attempt to store a cache entry with a null storage key.
*
* @throws Exception is expected
*/
@Test(expected = IllegalStateException.class)
public void testNullStorageKey() throws Exception {
final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
cacheObjectValues.storageKey = null;
final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
serializer.serialize(testEntry);
}
/**
* Deserialize a simple object, from a previously saved file.
*
* Ensures that if the serialization format changes in an incompatible way, we'll find out in a test.
*
* @throws Exception if anything goes wrong
*/
@Test
public void simpleTestFromPreviouslySerialized() throws Exception {
final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
verifyHttpCacheEntryFromTestFile(serializer, testEntry, SIMPLE_OBJECT_SERIALIZED_NAME, reserializeFiles);
}
/**
* Deserialize a larger object with a binary body, from a previously saved file.
*
* Ensures that if the serialization format changes in an incompatible way, we'll find out in a test.
*
* @throws Exception if anything goes wrong
*/
@Test
public void fileTestFromPreviouslySerialized() throws Exception {
final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
cacheObjectValues.resource = new FileResource(makeTestFileObject(TEST_CONTENT_FILE_NAME));
final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
verifyHttpCacheEntryFromTestFile(serializer, testEntry, FILE_TEST_SERIALIZED_NAME, reserializeFiles);
}
/**
* Deserialize a cache entry with a variant map, from a previously saved file.
*
* @throws Exception if anything goes wrong
*/
@Test
public void variantMapTestFromPreviouslySerialized() throws Exception {
final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
final Map<String, String> variantMap = new HashMap<>();
variantMap.put("{Accept-Encoding=gzip}","{Accept-Encoding=gzip}https://example.com:1234/foo");
variantMap.put("{Accept-Encoding=compress}","{Accept-Encoding=compress}https://example.com:1234/foo");
cacheObjectValues.variantMap = variantMap;
final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
verifyHttpCacheEntryFromTestFile(serializer, testEntry, VARIANTMAP_TEST_SERIALIZED_NAME, reserializeFiles);
}
/**
* Deserialize a cache entry with headers that use our pseudo-header prefix and need escaping.
*
* @throws Exception if anything goes wrong
*/
@Test
public void escapedHeaderTestFromPreviouslySerialized() throws Exception {
final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
cacheObjectValues.responseHeaders = new Header[] {
new BasicHeader("hc-test-1", "hc-test-1-value"),
new BasicHeader("hc-sk", "hc-sk-value"),
new BasicHeader("hc-resp-date", "hc-resp-date-value"),
new BasicHeader("hc-req-date-date", "hc-req-date-value"),
new BasicHeader("hc-varmap-key", "hc-varmap-key-value"),
new BasicHeader("hc-varmap-val", "hc-varmap-val-value"),
};
final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
verifyHttpCacheEntryFromTestFile(serializer, testEntry, ESCAPED_HEADER_TEST_SERIALIZED_NAME, reserializeFiles);
}
/**
* Deserialize a cache entry with no body, from a previously saved file.
*
* @throws Exception if anything goes wrong
*/
@Test
public void noBodyTestFromPreviouslySerialized() throws Exception {
final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
cacheObjectValues.resource = null;
cacheObjectValues.responseCode = 204;
final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
verifyHttpCacheEntryFromTestFile(serializer, testEntry, NO_BODY_TEST_SERIALIZED_NAME, reserializeFiles);
}
/**
* Deserialize a cache entry in a bad format, expecting an exception.
*
* @throws Exception is expected
*/
@Test(expected = ResourceIOException.class)
public void testInvalidCacheEntry() throws Exception {
// This file is a JPEG not a cache entry, so should fail to deserialize
final byte[] bytes = readTestFileBytes(TEST_CONTENT_FILE_NAME);
httpCacheStorageEntryFromBytes(serializer, bytes);
}
/**
* Deserialize a cache entry with a missing header, from a previously saved file.
*
* @throws Exception is expected
*/
@Test(expected = ResourceIOException.class)
public void testMissingHeaderCacheEntry() throws Exception {
// This file hand-edited to be missing a necessary header
final byte[] bytes = readTestFileBytes(MISSING_HEADER_TEST_SERIALIZED_NAME);
httpCacheStorageEntryFromBytes(serializer, bytes);
}
/**
* Deserialize a cache entry with an invalid header value, from a previously saved file.
*
* @throws Exception is expected
*/
@Test(expected = ResourceIOException.class)
public void testInvalidHeaderCacheEntry() throws Exception {
// This file hand-edited to have an invalid header
final byte[] bytes = readTestFileBytes(INVALID_HEADER_TEST_SERIALIZED_NAME);
httpCacheStorageEntryFromBytes(serializer, bytes);
}
/**
* Deserialize a cache entry with a missing variant map key, from a previously saved file.
*
* @throws Exception is expected
*/
@Test(expected = ResourceIOException.class)
public void testVariantMapMissingKeyCacheEntry() throws Exception {
// This file hand-edited to be missing a VariantCache key
final byte[] bytes = readTestFileBytes(VARIANTMAP_MISSING_KEY_TEST_SERIALIZED_NAME);
httpCacheStorageEntryFromBytes(serializer, bytes);
}
/**
* Deserialize a cache entry with a missing variant map value, from a previously saved file.
*
* @throws Exception is expected
*/
@Test(expected = ResourceIOException.class)
public void testVariantMapMissingValueCacheEntry() throws Exception {
// This file hand-edited to be missing a VariantCache value
final byte[] bytes = readTestFileBytes(VARIANTMAP_MISSING_VALUE_TEST_SERIALIZED_NAME);
httpCacheStorageEntryFromBytes(serializer, bytes);
}
/**
* Test an HttpException being thrown while serializing.
*
* @throws Exception is expected
*/
@Test(expected = ResourceIOException.class)
public void testSerializeWithHTTPException() throws Exception {
final AbstractMessageWriter<SimpleHttpResponse> throwyHttpWriter = Mockito.mock(AbstractMessageWriter.class);
Mockito.
doThrow(new HttpException("Test Exception")).
when(throwyHttpWriter).
write(Mockito.any(SimpleHttpResponse.class), Mockito.any(SessionOutputBuffer.class), Mockito.any(OutputStream.class));
final HttpCacheStorageEntryTestTemplate cacheObjectValues = HttpCacheStorageEntryTestTemplate.makeDefault();
final HttpCacheStorageEntry testEntry = cacheObjectValues.toEntry();
final HttpByteArrayCacheEntrySerializer testSerializer = new HttpByteArrayCacheEntrySerializer() {
protected AbstractMessageWriter<SimpleHttpResponse> makeHttpResponseWriter(final SessionOutputBuffer outputBuffer) {
return throwyHttpWriter;
}
};
testSerializer.serialize(testEntry);
}
/**
* Test an IOException being thrown while deserializing.
*
* @throws Exception is expected
*/
@Test(expected = ResourceIOException.class)
public void testDeserializeWithIOException() throws Exception {
final AbstractMessageParser<ClassicHttpResponse> throwyParser = Mockito.mock(AbstractMessageParser.class);
Mockito.
doThrow(new IOException("Test Exception")).
when(throwyParser).
parse(Mockito.any(SessionInputBuffer.class), Mockito.any(InputStream.class));
final HttpByteArrayCacheEntrySerializer testSerializer = new HttpByteArrayCacheEntrySerializer() {
@Override
protected AbstractMessageParser<ClassicHttpResponse> makeHttpResponseParser() {
return throwyParser;
}
};
testSerializer.deserialize(new byte[0]);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -0,0 +1,13 @@
HTTP/1.1 200 OK
Content-Length: 11
hc-esc-hc-test-1: hc-test-1-value
hc-esc-hc-sk: hc-sk-value
hc-esc-hc-resp-date: hc-resp-date-value
hc-esc-hc-req-date-date: hc-req-date-value
hc-esc-hc-varmap-key: hc-varmap-key-value
hc-esc-hc-varmap-val: hc-varmap-val-value
hc-sk: xyzzy
hc-resp-date: 2611108800000
hc-req-date: 165214800000
Hello World

View File

@ -0,0 +1,8 @@
HTTP/1.1 200 OK
Content-type: text/html
Cache-control: public, max-age=31536000
hc-sk: xyzzy
hc-resp-date: badbadbad
hc-req-date: 165214800000
Hello World

View File

@ -0,0 +1,7 @@
HTTP/1.1 200 OK
Content-type: text/html
Cache-control: public, max-age=31536000
hc-resp-date: 2611108800000
hc-req-date: 165214800000
Hello World

View File

@ -0,0 +1,8 @@
HTTP/1.1 204 No Content
Content-type: text/html
Cache-control: public, max-age=31536000
hc-sk: xyzzy
hc-resp-date: 2611108800000
hc-req-date: 165214800000
hc-no-content: true

View File

@ -0,0 +1,9 @@
HTTP/1.1 200 OK
Content-type: text/html
Cache-control: public, max-age=31536000
Content-Length: 11
hc-sk: xyzzy
hc-resp-date: 2611108800000
hc-req-date: 165214800000
Hello World

View File

@ -0,0 +1,13 @@
HTTP/1.1 200 OK
Content-type: text/html
Cache-control: public, max-age=31536000
Content-Length: 11
hc-sk: xyzzy
hc-resp-date: 2611108800000
hc-req-date: 165214800000
hc-varmap-key: {Accept-Encoding=gzip}
hc-varmap-val: {Accept-Encoding=gzip}https://example.com:1234/foo
hc-varmap-key: {Accept-Encoding=compress}
hc-varmap-val: {Accept-Encoding=compress}https://example.com:1234/foo
Hello World

View File

@ -0,0 +1,9 @@
HTTP/1.1 200 OK
Content-type: text/html
Cache-control: public, max-age=31536000
hc-sk: xyzzy
hc-resp-date: 2611108800000
hc-req-date: 165214800000
hc-varmap-val: {Accept-Encoding=compress}https://example.com:1234/foo
Hello World

View File

@ -0,0 +1,9 @@
HTTP/1.1 200 OK
Content-type: text/html
Cache-control: public, max-age=31536000
hc-sk: xyzzy
hc-resp-date: 2611108800000
hc-req-date: 165214800000
hc-varmap-key: {Accept-Encoding=gzip}
Hello World

View File

@ -303,6 +303,7 @@
<excludes>
<exclude>src/docbkx/resources/**</exclude>
<exclude>src/test/resources/*.truststore</exclude>
<exclude>src/test/resources/*.serialized</exclude>
<exclude>.checkstyle</exclude>
<exclude>.externalToolBuilders/**</exclude>
<exclude>maven-eclipse.xml</exclude>