TestHttpByteArrayCacheEntrySerializer.java

/*
 * ====================================================================
 * 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.net.URL;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;

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.ResourceIOException;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.HttpHeaders;
import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.http.message.BasicHeader;
import org.apache.hc.core5.util.ByteArrayBuffer;
import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class TestHttpByteArrayCacheEntrySerializer {

    private HttpCacheEntrySerializer<byte[]> httpCacheEntrySerializer;

    @BeforeEach
    void before() {
        httpCacheEntrySerializer = HttpByteArrayCacheEntrySerializer.INSTANCE;
    }

    @Test
    void testSimpleSerializeAndDeserialize() throws Exception {
        final String content = "Hello World";
        final ContentType contentType = ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8);
        final HttpCacheEntry cacheEntry = new HttpCacheEntry(Instant.now(), Instant.now(),
                "GET", "/stuff", HttpTestUtils.headers(),
                HttpStatus.SC_OK, HttpTestUtils.headers(new BasicHeader(HttpHeaders.CONTENT_TYPE, contentType.toString())),
                new HeapResource(content.getBytes(contentType.getCharset())),
                null);
        final HttpCacheStorageEntry storageEntry = new HttpCacheStorageEntry("unique-cache-key", cacheEntry);
        final byte[] serialized = httpCacheEntrySerializer.serialize(storageEntry);

        final HttpCacheStorageEntry deserialized = httpCacheEntrySerializer.deserialize(serialized);
        MatcherAssert.assertThat(deserialized.getKey(), Matchers.equalTo(storageEntry.getKey()));
        MatcherAssert.assertThat(deserialized.getContent(), HttpCacheEntryMatcher.equivalent(storageEntry.getContent()));
    }

    @Test
    void testSerializeAndDeserializeLargeContent() throws Exception {
        final ContentType contentType = ContentType.IMAGE_JPEG;
        final HeapResource resource = load(getClass().getResource("/ApacheLogo.png"));
        final HttpCacheEntry cacheEntry = new HttpCacheEntry(Instant.now(), Instant.now(),
                "GET", "/stuff", HttpTestUtils.headers(),
                HttpStatus.SC_OK, HttpTestUtils.headers(new BasicHeader(HttpHeaders.CONTENT_TYPE, contentType.toString())),
                resource,
                null);
        final HttpCacheStorageEntry storageEntry = new HttpCacheStorageEntry("unique-cache-key", cacheEntry);
        final byte[] serialized = httpCacheEntrySerializer.serialize(storageEntry);

        final HttpCacheStorageEntry deserialized = httpCacheEntrySerializer.deserialize(serialized);
        MatcherAssert.assertThat(deserialized.getKey(), Matchers.equalTo(storageEntry.getKey()));
        MatcherAssert.assertThat(deserialized.getContent(), HttpCacheEntryMatcher.equivalent(storageEntry.getContent()));
    }

    /**
     * Deserialize a cache entry in a bad format, expecting an exception.
     */
    @Test
    void testInvalidCacheEntry() throws Exception {
        // This file is a JPEG not a cache entry, so should fail to deserialize
        final HeapResource resource = load(getClass().getResource("/ApacheLogo.png"));
        Assertions.assertThrows(ResourceIOException.class, () ->
                httpCacheEntrySerializer.deserialize(resource.get()));
    }

    /**
     * Deserialize truncated cache entries.
     */
    @Test
    void testTruncatedCacheEntry() {
        final String content1 = HttpByteArrayCacheEntrySerializer.HC_CACHE_VERSION_LINE + "\n" +
                "HC-Key: unique-cache-key\n" +
                "HC-Resource-Length: 11\n" +
                "HC-Request-Instant: 1686210849596\n" +
                "HC-Response-Instant: 1686210849596\n" +
                "\n" +
                "GET /stuff HTTP/1.1\n" +
                "\n" +
                "HTTP/1.1 200 \n" +
                "Content-Type: text/plain; charset=UTF-8\n" +
                "Cache-control: public, max-age=31536000\n" +
                "\n" +
                "Huh?";
        final byte[] bytes1 = content1.getBytes(StandardCharsets.UTF_8);
        final ResourceIOException exception1 = Assertions.assertThrows(ResourceIOException.class, () ->
                httpCacheEntrySerializer.deserialize(bytes1));
        Assertions.assertEquals("Unexpected end of cache content", exception1.getMessage());

        final String content2 = HttpByteArrayCacheEntrySerializer.HC_CACHE_VERSION_LINE + "\n" +
                "HC-Key: unique-cache-key\n" +
                "HC-Resource-Length: 11\n" +
                "HC-Request-Instant: 1686210849596\n" +
                "HC-Response-Instant: 1686210849596\n" +
                "\n" +
                "GET /stuff HTTP/1.1\n" +
                "\n" +
                "HTTP/1.1 200 \n" +
                "Content-Type: text/plain; charset=UTF-8\n" +
                "Cache-control: public, max-age=31536000\n";
        final byte[] bytes2 = content2.getBytes(StandardCharsets.UTF_8);
        final ResourceIOException exception2 = Assertions.assertThrows(ResourceIOException.class, () ->
                httpCacheEntrySerializer.deserialize(bytes2));
        Assertions.assertEquals("Unexpected end of stream", exception2.getMessage());

        final String content3 = HttpByteArrayCacheEntrySerializer.HC_CACHE_VERSION_LINE + "\n" +
                "HC-Key: unique-cache-key\n" +
                "HC-Resource-Length: 11\n" +
                "HC-Request-Instant: 1686210849596\n" +
                "HC-Response-Instant: 1686210849596\n" +
                "\n" +
                "GET /stuff HTTP/1.1\n" +
                "\n";
        final byte[] bytes3 = content3.getBytes(StandardCharsets.UTF_8);
        final ResourceIOException exception3 = Assertions.assertThrows(ResourceIOException.class, () ->
                httpCacheEntrySerializer.deserialize(bytes3));
        Assertions.assertEquals("Unexpected end of stream", exception3.getMessage());

        final String content4 = HttpByteArrayCacheEntrySerializer.HC_CACHE_VERSION_LINE + "\n" +
                "HC-Key: unique-cache-key\n" +
                "HC-Resource-Length: 11\n" +
                "HC-Request-Instant: 1686210849596\n" +
                "HC-Response-Instant: 1686210849596\n";
        final byte[] bytes4 = content4.getBytes(StandardCharsets.UTF_8);
        final ResourceIOException exception4 = Assertions.assertThrows(ResourceIOException.class, () ->
                httpCacheEntrySerializer.deserialize(bytes4));
        Assertions.assertEquals("Unexpected end of stream", exception4.getMessage());

        final String content5 = HttpByteArrayCacheEntrySerializer.HC_CACHE_VERSION_LINE + "\n" +
                "HC-Key: unique-cache-key\n";
        final byte[] bytes5 = content5.getBytes(StandardCharsets.UTF_8);
        final ResourceIOException exception5 = Assertions.assertThrows(ResourceIOException.class, () ->
                httpCacheEntrySerializer.deserialize(bytes5));
        Assertions.assertEquals("Unexpected end of stream", exception5.getMessage());

        final String content6 = HttpByteArrayCacheEntrySerializer.HC_CACHE_VERSION_LINE + "\n";
        final byte[] bytes6 = content6.getBytes(StandardCharsets.UTF_8);
        final ResourceIOException exception6 = Assertions.assertThrows(ResourceIOException.class, () ->
                httpCacheEntrySerializer.deserialize(bytes6));
        Assertions.assertEquals("Unexpected end of stream", exception6.getMessage());

        final String content7 = "HttpClient CacheEntry 1\n";
        final byte[] bytes7 = content7.getBytes(StandardCharsets.UTF_8);
        final ResourceIOException exception7 = Assertions.assertThrows(ResourceIOException.class, () ->
                httpCacheEntrySerializer.deserialize(bytes7));
        Assertions.assertEquals("Unexpected cache entry version line", exception7.getMessage());
    }

    /**
     * Deserialize cache entries with a missing mandatory header.
     */
    @Test
    void testMissingHeaderCacheEntry() {
        final String content1 = HttpByteArrayCacheEntrySerializer.HC_CACHE_VERSION_LINE + "\n" +
                "HC-Key: unique-cache-key\n" +
                "HC-Resource-Length: 11\n" +
                "HC-Response-Instant: 1686210849596\n" +
                "\n" +
                "GET /stuff HTTP/1.1\n" +
                "\n" +
                "HTTP/1.1 200 \n" +
                "Content-Type: text/plain; charset=UTF-8\n" +
                "Cache-control: public, max-age=31536000\n" +
                "\n" +
                "Hello World";
        final byte[] bytes1 = content1.getBytes(StandardCharsets.UTF_8);
        final ResourceIOException exception1 = Assertions.assertThrows(ResourceIOException.class, () ->
                httpCacheEntrySerializer.deserialize(bytes1));
        Assertions.assertEquals("Invalid cache header format", exception1.getMessage());

        final String content2 = HttpByteArrayCacheEntrySerializer.HC_CACHE_VERSION_LINE + "\n" +
                "HC-Key: unique-cache-key\n" +
                "HC-Resource-Length: 11\n" +
                "HC-Request-Instant: 1686210849596\n" +
                "\n" +
                "GET /stuff HTTP/1.1\n" +
                "\n" +
                "HTTP/1.1 200 \n" +
                "Content-Type: text/plain; charset=UTF-8\n" +
                "Cache-control: public, max-age=31536000\n" +
                "\n" +
                "Hello World";
        final byte[] bytes2 = content2.getBytes(StandardCharsets.UTF_8);
        final ResourceIOException exception2 = Assertions.assertThrows(ResourceIOException.class, () ->
                httpCacheEntrySerializer.deserialize(bytes2));
        Assertions.assertEquals("Invalid cache header format", exception2.getMessage());
    }

    /**
     * Deserialize cache entries with an invalid header value.
     */
    @Test
    void testInvalidHeaderCacheEntry() {
        final String content1 = HttpByteArrayCacheEntrySerializer.HC_CACHE_VERSION_LINE + "\n" +
                "HC-Key: unique-cache-key\n" +
                "HC-Resource-Length: 11\n" +
                "HC-Request-Instant: boom\n" +
                "HC-Response-Instant: 1686210849596\n" +
                "\n" +
                "GET /stuff HTTP/1.1\n" +
                "\n" +
                "HTTP/1.1 200 \n" +
                "Content-Type: text/plain; charset=UTF-8\n" +
                "Cache-control: public, max-age=31536000\n" +
                "\n" +
                "Hello World";
        final byte[] bytes1 = content1.getBytes(StandardCharsets.UTF_8);
        final ResourceIOException exception1 = Assertions.assertThrows(ResourceIOException.class, () ->
                httpCacheEntrySerializer.deserialize(bytes1));
        Assertions.assertEquals("Invalid cache header format", exception1.getMessage());
        final String content2 = HttpByteArrayCacheEntrySerializer.HC_CACHE_VERSION_LINE + "\n" +
                "HC-Key: unique-cache-key\n" +
                "HC-Resource-Length: 11\n" +
                "HC-Request-Instant: 1686210849596\n" +
                "HC-Response-Instant: boom\n" +
                "\n" +
                "GET /stuff HTTP/1.1\n" +
                "\n" +
                "HTTP/1.1 200 \n" +
                "Content-Type: text/plain; charset=UTF-8\n" +
                "Cache-control: public, max-age=31536000\n" +
                "\n" +
                "Hello World";
        final byte[] bytes2 = content2.getBytes(StandardCharsets.UTF_8);
        final ResourceIOException exception2 = Assertions.assertThrows(ResourceIOException.class, () ->
                httpCacheEntrySerializer.deserialize(bytes2));
        Assertions.assertEquals("Invalid cache header format", exception2.getMessage());
    }

    /**
     * Deserialize cache entries with an invalid request line.
     */
    @Test
    void testInvalidRequestLineCacheEntry() {
        final String content1 = HttpByteArrayCacheEntrySerializer.HC_CACHE_VERSION_LINE + "\n" +
                "HC-Key: unique-cache-key\n" +
                "HC-Resource-Length: 11\n" +
                "HC-Request-Instant: 1686210849596\n" +
                "HC-Response-Instant: 1686210849596\n" +
                "\n" +
                "GET boom\n" +
                "\n" +
                "HTTP/1.1 200 \n" +
                "Content-Type: text/plain; charset=UTF-8\n" +
                "Cache-control: public, max-age=31536000\n" +
                "\n" +
                "Hello World";
        final byte[] bytes1 = content1.getBytes(StandardCharsets.UTF_8);
        final ResourceIOException exception1 = Assertions.assertThrows(ResourceIOException.class, () ->
                httpCacheEntrySerializer.deserialize(bytes1));
        Assertions.assertEquals("Invalid cache header format", exception1.getMessage());
    }

    /**
     * Deserialize cache entries with an invalid request line.
     */
    @Test
    void testInvalidStatusLineCacheEntry() {
        final String content1 = HttpByteArrayCacheEntrySerializer.HC_CACHE_VERSION_LINE + "\n" +
                "HC-Key: unique-cache-key\n" +
                "HC-Resource-Length: 11\n" +
                "HC-Request-Instant: 1686210849596\n" +
                "HC-Response-Instant: 1686210849596\n" +
                "\n" +
                "GET /stuff HTTP/1.1\n" +
                "\n" +
                "HTTP/1.1 boom \n" +
                "Content-Type: text/plain; charset=UTF-8\n" +
                "Cache-control: public, max-age=31536000\n" +
                "\n" +
                "Hello World";
        final byte[] bytes1 = content1.getBytes(StandardCharsets.UTF_8);
        final ResourceIOException exception1 = Assertions.assertThrows(ResourceIOException.class, () ->
                httpCacheEntrySerializer.deserialize(bytes1));
        Assertions.assertEquals("Invalid cache header format", exception1.getMessage());
    }

    /**
     * Serialize and deserialize a cache entry with no headers.
     */
    @Test
    void noHeadersTest() throws Exception {
        final String content = "Hello World";
        final ContentType contentType = ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8);
        final HttpCacheEntry cacheEntry = new HttpCacheEntry(Instant.now(), Instant.now(),
                "GET", "/stuff", HttpTestUtils.headers(),
                HttpStatus.SC_OK, HttpTestUtils.headers(),
                new HeapResource(content.getBytes(contentType.getCharset())),
                null);
        final HttpCacheStorageEntry storageEntry = new HttpCacheStorageEntry("unique-cache-key", cacheEntry);
        final byte[] serialized = httpCacheEntrySerializer.serialize(storageEntry);

        final HttpCacheStorageEntry deserialized = httpCacheEntrySerializer.deserialize(serialized);
        MatcherAssert.assertThat(deserialized.getKey(), Matchers.equalTo(storageEntry.getKey()));
        MatcherAssert.assertThat(deserialized.getContent(), HttpCacheEntryMatcher.equivalent(storageEntry.getContent()));
    }

    /**
     * Serialize and deserialize a cache entry with an empty body.
     */
    @Test
    void emptyBodyTest() throws Exception {
        final HttpCacheEntry cacheEntry = new HttpCacheEntry(Instant.now(), Instant.now(),
                "GET", "/stuff", HttpTestUtils.headers(),
                HttpStatus.SC_OK, HttpTestUtils.headers(),
                new HeapResource(new byte[] {}),
                null);
        final HttpCacheStorageEntry storageEntry = new HttpCacheStorageEntry("unique-cache-key", cacheEntry);
        final byte[] serialized = httpCacheEntrySerializer.serialize(storageEntry);

        final HttpCacheStorageEntry deserialized = httpCacheEntrySerializer.deserialize(serialized);
        MatcherAssert.assertThat(deserialized.getKey(), Matchers.equalTo(storageEntry.getKey()));
        MatcherAssert.assertThat(deserialized.getContent(), HttpCacheEntryMatcher.equivalent(storageEntry.getContent()));
    }

    /**
     * Serialize and deserialize a cache entry with no body.
     */
    @Test
    void noBodyTest() throws Exception {
        final HttpCacheEntry cacheEntry = new HttpCacheEntry(Instant.now(), Instant.now(),
                "GET", "/stuff", HttpTestUtils.headers(),
                HttpStatus.SC_OK, HttpTestUtils.headers(),
                null,
                null);
        final HttpCacheStorageEntry storageEntry = new HttpCacheStorageEntry("unique-cache-key", cacheEntry);
        final byte[] serialized = httpCacheEntrySerializer.serialize(storageEntry);

        final HttpCacheStorageEntry deserialized = httpCacheEntrySerializer.deserialize(serialized);
        MatcherAssert.assertThat(deserialized.getKey(), Matchers.equalTo(storageEntry.getKey()));
        MatcherAssert.assertThat(deserialized.getContent(), HttpCacheEntryMatcher.equivalent(storageEntry.getContent()));
    }

    /**
     * Serialize and deserialize a cache entry with a variant map.
     */
    @Test
    void testSimpleVariantMap() throws Exception {
        final String content = "Hello World";
        final ContentType contentType = ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8);
        final Set<String> variants = new HashSet<>();
        variants.add("{Accept-Encoding=gzip}");
        variants.add("{Accept-Encoding=compress}");
        final HttpCacheEntry cacheEntry = new HttpCacheEntry(Instant.now(), Instant.now(),
                "GET", "/stuff", HttpTestUtils.headers(),
                HttpStatus.SC_OK, HttpTestUtils.headers(new BasicHeader(HttpHeaders.CONTENT_TYPE, contentType.toString())),
                new HeapResource(content.getBytes(contentType.getCharset())),
                variants);
        final HttpCacheStorageEntry storageEntry = new HttpCacheStorageEntry("unique-cache-key", cacheEntry);
        final byte[] serialized = httpCacheEntrySerializer.serialize(storageEntry);

        final HttpCacheStorageEntry deserialized = httpCacheEntrySerializer.deserialize(serialized);
        MatcherAssert.assertThat(deserialized.getKey(), Matchers.equalTo(storageEntry.getKey()));
        MatcherAssert.assertThat(deserialized.getContent(), HttpCacheEntryMatcher.equivalent(storageEntry.getContent()));
    }

    /**
     * Deserialize cache entries with trailing garbage.
     */
    @Test
    void testDeserializeCacheEntryWithTrailingGarbage() {
        final String content1 = HttpByteArrayCacheEntrySerializer.HC_CACHE_VERSION_LINE + "\n" +
                "HC-Key: unique-cache-key\n" +
                "HC-Resource-Length: 11\n" +
                "HC-Request-Instant: 1686210849596\n" +
                "HC-Response-Instant: 1686210849596\n" +
                "\n" +
                "GET /stuff HTTP/1.1\n" +
                "\n" +
                "HTTP/1.1 200 \n" +
                "Content-Type: text/plain; charset=UTF-8\n" +
                "Cache-control: public, max-age=31536000\n" +
                "\n" +
                "Hello World..... Rubbish";
        final byte[] bytes1 = content1.getBytes(StandardCharsets.UTF_8);
        final ResourceIOException exception1 = Assertions.assertThrows(ResourceIOException.class, () ->
                httpCacheEntrySerializer.deserialize(bytes1));
        Assertions.assertEquals("Unexpected content at the end of cache content", exception1.getMessage());

        final String content2 = HttpByteArrayCacheEntrySerializer.HC_CACHE_VERSION_LINE + "\n" +
                "HC-Key: unique-cache-key\n" +
                "HC-Request-Instant: 1686210849596\n" +
                "HC-Response-Instant: 1686210849596\n" +
                "\n" +
                "GET /stuff HTTP/1.1\n" +
                "\n" +
                "HTTP/1.1 200 \n" +
                "Content-Type: text/plain; charset=UTF-8\n" +
                "Cache-control: public, max-age=31536000\n" +
                "\n" +
                "Rubbish";
        final byte[] bytes2 = content2.getBytes(StandardCharsets.UTF_8);
        final ResourceIOException exception2 = Assertions.assertThrows(ResourceIOException.class, () ->
                httpCacheEntrySerializer.deserialize(bytes2));
        Assertions.assertEquals("Unexpected content at the end of cache content", exception2.getMessage());
    }

    static HeapResource load(final URL resource) throws IOException {
        try (final InputStream in = resource.openStream()) {
            final ByteArrayBuffer buf = new ByteArrayBuffer(1024);
            final byte[] tmp = new byte[2048];
            int len;
            while ((len = in.read(tmp)) != -1) {
                buf.append(tmp, 0, len);
            }
            return new HeapResource(buf.toByteArray());
        }
    }

}