A java.net.ResponseCache Implementation

The Java URLConnection mechanism may be configured to use a ResponseCache. This article describes a ResponseCache implementation.

Implementation Outline

The implementation requires subclassing ResponseCache, providing implementations of get(URI,String,Map<String,List<String>>) and put(URI,URLConnection). Non-trivial implementations of each method require providing concrete implementations of CacheResponse and CacheRequest. The outline of the implementation is:

import java.net.CacheRequest;
import java.net.CacheResponse;
import java.net.ResponseCache;
import java.net.URI;
import java.net.URLConnection;

public class ResponseCacheImpl extends ResponseCache {

    /**
     * Default {@link ResponseCacheImpl}.
     */
    public static final ResponseCacheImpl DEFAULT = new ResponseCacheImpl();

    private ResponseCacheImpl() {
        super();
        /*
         * ...
         */
    }

    @Override
    public CacheResponse get(URI uri, String method,
                             Map<String,List<String>> headers) {
        CacheResponseImpl response = null;
        /*
         * ...
         */
        return response;
    }

    @Override
    public CacheRequest put(URI uri, URLConnection connection) {
        CacheRequestImpl request = null;
        /*
         * ...
         */
        return request;
    }

    public class CacheRequestImpl extends CacheRequest {
        /*
         * ...
         */
    }

    public class CacheResponseImpl extends CacheResponse {
        /*
         * ...
         */
    }
}

Note: The get() and put() methods may return null indicating that no caching facility is available for that URI.

Cache Design

The cache will be a simple file system hierarchy residing under ${user.home}/.config/java/cache/. A cached URI will map to a directory which will exist and contain two files if the object is cached: BODY and HEADERS.

    private Path cache(URI uri) {
        Path path = cache.resolve(uri.getScheme().toLowerCase());
        String host = uri.getHost().toLowerCase();
        int port = uri.getPort();

        if (port > 0) {
            host += ":" + String.valueOf(port);
        }

        path = path.resolve(host);

        String string = uri.getPath();

        if (string != null) {
            for (String substring : string.split("[/]+")) {
                if (isNotEmpty(substring)) {
                    path = path.resolve(substring);
                }
            }
        }

        return path.normalize();
    }

No attempt will be made to cache “complex” URIs. isCacheable is defined as:

    private boolean isCacheable(URI uri) {
        return (uri.isAbsolute()
                && (! uri.isOpaque())
                && uri.getUserInfo() == null
                && uri.getQuery() == null
                && uri.getFragment() == null);
    }

A URI is cached if it isCacheable() and its body exists in the cache:

    private boolean isCached(URI uri) {
        return isCacheable(uri) && Files.exists(cache(uri).resolve(BODY));
    }

ResponseCache.put(URI,URLConnection) Implementation

This method allows the caller to put an object in the cache. URLConnection.getHeaderFields() are saved along with the “body” using an XMLEncoder.

    @Override
    public CacheRequest put(URI uri, URLConnection connection) {
        CacheRequestImpl request = null;

        if (isCacheable(uri)) {
            if (! connection.getAllowUserInteraction()) {
                request =
                    new CacheRequestImpl(cache(uri),
                                         connection.getHeaderFields());
            }
        }

        return request;
    }

    public class CacheRequestImpl extends CacheRequest {
        private final Path path;
        private final Map<String,List<String>> headers;

        private CacheRequestImpl(Path path, Map<String,List<String>> headers) {
            super();

            this.path = Objects.requireNonNull(path);
            this.headers = Objects.requireNonNull(headers);
        }

        @Override
        public OutputStream getBody() throws IOException {
            Files.createDirectories(path);

            XMLEncoder encoder =
                new XMLEncoder(Files.newOutputStream(path.resolve(HEADERS)));

            encoder.writeObject(headers);
            encoder.close();

            return Files.newOutputStream(path.resolve(BODY));
        }

ResponseCache.get(URI,String,Map<String,List<String>>) Implementation

This method attempts to retrieve the object from cache. If it is cached, CacheResponseImpl provides the previously saved headers and InputStream from the cached file. Headers are deserialized with an XMLDecoder.

    @Override
    public CacheResponse get(URI uri, String method,
                             Map<String,List<String>> headers) {
        CacheResponseImpl response = null;

        if (isCached(uri)) {
            response = new CacheResponseImpl(cache(uri));
        }

        return response;
    }

    public class CacheResponseImpl extends CacheResponse {
        private final Path path;

        private CacheResponseImpl(Path path) {
            super();

            this.path = Objects.requireNonNull(path);
        }

        @Override
        public Map<String,List<String>> getHeaders() throws IOException {
            XMLDecoder decoder =
                new XMLDecoder(Files.newInputStream(path.resolve(HEADERS)));
            @SuppressWarnings("unchecked")
            Map<String,List<String>> headers =
                (Map<String,List<String>>) decoder.readObject();

            decoder.close();

            return headers;
        }

        @Override
        public InputStream getBody() throws IOException {
            return Files.newInputStream(path.resolve(BODY));
        }
    }

Installation

The ResponseCache implementation must be configured with the ResponseCache.setDefault(ResponseCache) static method. The following code fragment checks to see no other ResponseCache is installed before installing the target.

        if (ResponseCache.getDefault() == null) {
            ResponseCache.setDefault(ResponseCacheImpl.DEFAULT);
        }

Ant Task

The critical portions of an Ant Task that uses URLConnection configured with ResponseCacheImpl for download is shown below.

@AntTask("download")
@NoArgsConstructor @ToString
public class DownloadTask extends Task {
    static {
        if (ResponseCache.getDefault() == null) {
            ResponseCache.setDefault(ResponseCacheImpl.DEFAULT);
        }
    }

    @NotNull @Getter @Setter
    private URI uri = null;
    @NotNull @Getter @Setter
    private File toFile = null;

    @Override
    public void execute() throws BuildException {
        try {
            URLConnection connection = getUri().toURL().openConnection();

            IOUtils.copy(connection.getInputStream(),
                         new FileOutputStream(getToFile()));

            log(getUri() + " --> " + getToFile());
        } catch (BuildException exception) {
            throw exception;
        } catch (RuntimeException exception) {
            throw exception;
        } catch (Exception exception) {
            throw new BuildException(exception);
        }
    }
}

ResponseCacheImpl.java

The complete implementation:

/*
 * $Id: README.md 3848 2019-03-10 17:48:38Z ball $
 *
 * Copyright 2019 Allen D. Ball.  All rights reserved.
 */
package ball.net;

import java.beans.XMLDecoder;
import java.beans.XMLEncoder;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.CacheRequest;
import java.net.CacheResponse;
import java.net.ResponseCache;
import java.net.URI;
import java.net.URLConnection;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.PosixFilePermission;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

import static java.nio.file.attribute.PosixFilePermissions.asFileAttribute;
import static java.nio.file.attribute.PosixFilePermissions.fromString;
import static org.apache.commons.lang3.StringUtils.isNotEmpty;

/**
 * {@link ResponseCache} implementation.
 *
 * @author {@link.uri mailto:ball@iprotium.com Allen D. Ball}
 * @version $Revision: 3848 $
 */
public class ResponseCacheImpl extends ResponseCache {

    /**
     * Default {@link ResponseCacheImpl}.
     */
    public static final ResponseCacheImpl DEFAULT = new ResponseCacheImpl();

    private static final String BODY = "BODY";
    private static final String HEADERS = "HEADERS";

    private final Path cache;

    private ResponseCacheImpl() {
        super();

        try {
            cache =
                Paths.get(System.getProperty("user.home"),
                          ".config", "java", "cache");

            Files.createDirectories(cache,
                                    asFileAttribute(fromString("rwx------")));
        } catch (Exception exception) {
            throw new ExceptionInInitializerError(exception);
        }
    }

    @Override
    public CacheResponse get(URI uri, String method,
                             Map<String,List<String>> headers) {
        CacheResponseImpl response = null;

        if (isCached(uri)) {
            response = new CacheResponseImpl(cache(uri));
        }

        return response;
    }

    @Override
    public CacheRequest put(URI uri, URLConnection connection) {
        CacheRequestImpl request = null;

        if (isCacheable(uri)) {
            if (! connection.getAllowUserInteraction()) {
                request =
                    new CacheRequestImpl(cache(uri),
                                         connection.getHeaderFields());
            }
        }

        return request;
    }

    private Path cache(URI uri) {
        Path path = cache.resolve(uri.getScheme().toLowerCase());
        String host = uri.getHost().toLowerCase();
        int port = uri.getPort();

        if (port > 0) {
            host += ":" + String.valueOf(port);
        }

        path = path.resolve(host);

        String string = uri.getPath();

        if (string != null) {
            for (String substring : string.split("[/]+")) {
                if (isNotEmpty(substring)) {
                    path = path.resolve(substring);
                }
            }
        }

        return path.normalize();
    }

    private boolean isCached(URI uri) {
        return isCacheable(uri) && Files.exists(cache(uri).resolve(BODY));
    }

    private boolean isCacheable(URI uri) {
        return (uri.isAbsolute()
                && (! uri.isOpaque())
                && uri.getUserInfo() == null
                && uri.getQuery() == null
                && uri.getFragment() == null);
    }

    private void delete(Path path) throws IOException {
        Files.deleteIfExists(path.resolve(HEADERS));
        Files.deleteIfExists(path.resolve(BODY));
        Files.deleteIfExists(path);
    }

    public class CacheRequestImpl extends CacheRequest {
        private final Path path;
        private final Map<String,List<String>> headers;

        private CacheRequestImpl(Path path, Map<String,List<String>> headers) {
            super();

            this.path = Objects.requireNonNull(path);
            this.headers = Objects.requireNonNull(headers);
        }

        @Override
        public OutputStream getBody() throws IOException {
            Files.createDirectories(path);

            XMLEncoder encoder =
                new XMLEncoder(Files.newOutputStream(path.resolve(HEADERS)));

            encoder.writeObject(headers);
            encoder.close();

            return Files.newOutputStream(path.resolve(BODY));
        }

        @Override
        public void abort() {
            try {
                delete(path);
            } catch (Exception exception) {
                throw new IllegalStateException(exception);
            }
        }
    }

    public class CacheResponseImpl extends CacheResponse {
        private final Path path;

        private CacheResponseImpl(Path path) {
            super();

            this.path = Objects.requireNonNull(path);
        }

        @Override
        public Map<String,List<String>> getHeaders() throws IOException {
            XMLDecoder decoder =
                new XMLDecoder(Files.newInputStream(path.resolve(HEADERS)));
            @SuppressWarnings("unchecked")
            Map<String,List<String>> headers =
                (Map<String,List<String>>) decoder.readObject();

            decoder.close();

            return headers;
        }

        @Override
        public InputStream getBody() throws IOException {
            return Files.newInputStream(path.resolve(BODY));
        }
    }
}