macgregor/alexandria

View on GitHub
alexandria-core/src/main/java/com/github/macgregor/alexandria/remotes/RemoteDocument.java

Summary

Maintainability
C
1 day
Test Coverage
package com.github.macgregor.alexandria.remotes;

import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.macgregor.alexandria.Jackson;
import com.github.macgregor.alexandria.exceptions.HttpException;
import lombok.*;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.apache.commons.lang3.StringUtils;

import java.io.IOException;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;

/**
 * Abstraction around a remote document format to make get/put/post/delete methods against a rest api.
 *
 * The class is generic, taking a POJO that makes up the request body for put/post and the response for gets.
 * The class handles error handling and marshalling for you, leaving less boiler plate in the actual application logic.
 *
 * The value of this class may be limited by variablility in rest api standards. If assumptions made by this class are
 * broken it may need to be updated and or made extensible or remotes to take advantage of it. The basics should apply, but
 * implementation details like POST requests returning the created document may not.
 *
 * <pre>
 * {@code
 * class Foo{
 *     Integer id;
 *     String name;
 *     String address;
 * }
 *
 * RemoteDocument<Foo> remoteFoo = RemoteDocument.<Foo>builder()
 *          .baseUrl("www.google.com")
 *          .pathSegment("foo")
 *          .pathSegment("1")
 *          .header("Authorization", Credentials.basic("username", "password"))
 *          .queryParameter("fields", "id,name,address")
 *          .build();
 *
 * Foo foo = remoteFoo.get();
 * }
 * </pre>
 *
 * @param <T>  POJO representing the remote document for both requests and responses
 */
@Slf4j
@Getter @Setter @Accessors(fluent = true)
@Builder(toBuilder = true)
public class RemoteDocument<T>{
    public static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");

    /** Base url for requests */
    @NonNull private String baseUrl;

    /** Class matching T used to prevent runtime type erasure by JVM */
    @NonNull private Class<T> entity;

    /** Timeout for all requests. Default: 30 seconds. */
    @Builder.Default private Integer requestTimeout = 30;

    /** Headers to add to requests. */
    @Singular private Map<String, String> headers;

    /** Query parameters to add to requests */
    @Singular private Map<String, String> queryParameters;

    /** Path segments to add to {@link #baseUrl} to create request url. */
    @Singular private List<String> pathSegments;

    /** Currenly unimplemented. */
    @Singular private List<Integer> allowableStatusCodes;

    /** name of the query parameter used by the remote api to specify page offset */
    @Builder.Default private String pageOffsetRequestParameter = "startIndex";

    /** name of the query parameter used by the remote api to specify number of items per page */
    @Builder.Default private String pageSizeRequestParameter = "count";

    /** what field in the response contains the T entities to parse */
    @Builder.Default private String pageListResponseField = "list";

    /**
     * Get a single document from the remote, parsing the request result into a POJO.
     *
     * The builder should be configured such that the rest api will only return a single document, for example
     * www.jive.come/contents/1234 would return the Jive document with an id of 1234. It is on the remote implementation
     * to correctly use the api to achieve this.
     *
     * Exact semantics for how the remote interprets the request may vary.
     *
     * To retrieve multiple paged documents see {@link #getPaged()}.
     *
     * @return  A parsed POJO object representing the remote document
     * @throws HttpException  If a non 20X status code results from the request or an unchecked exception occurs.
     */
    public T get() throws HttpException {
        Request request = null;

        try {
            request = Requests.requestBuilder(route(), headers).get().build();
            Response response = doRequest(request);
            return parseResponse(response);
        } catch(HttpException e){
            e.request(Optional.of(request));
            throw e;
        } catch(Exception e){
            throw new HttpException.Builder()
                    .withMessage("Unexpected error executing GET")
                    .causedBy(e)
                    .requestContext(request)
                    .build();
        }
    }

    /**
     * Get an iterable to page through remote documents matching the request.
     *
     * @see RemoteDocumentPage
     * @see RemoteDocumentIterator
     *
     * @return  An iterable of all remote documents matching the request.
     */
    public RemoteDocumentPage<T> getPaged(){
        return new RemoteDocumentPage(this.toBuilder());
    }

    /**
     * Create a PUT request to update the remote document with the supplied values.
     *
     * The builder should be configured such that the rest api will only return a single document, for example
     * www.jive.come/contents/1234 would return the Jive document with an id of 1234. It is on the remote implementation
     * to correctly use the api to achieve this.
     *
     * Exact semantics for how the remote interprets the request may vary.
     *
     * @param t  The new values for the remote object
     * @return  The response from the server, expected to be the new updated remote document, but different rest apis may
     *          behave differently.
     * @throws HttpException  If a non 20X status code results from the request or an unchecked exception occurs.
     */
    public T put(T t) throws HttpException {
        Request request = null;
        try {
            request = Requests.requestBuilder(route(), headers)
                    .put(requestBody(t))
                    .build();
            Response response = doRequest(request);
            return parseResponse(response);
        } catch(HttpException e){
            e.request(Optional.of(request));
            throw e;
        } catch(Exception e){
            throw new HttpException.Builder()
                    .withMessage("Unexpected error executing PUT")
                    .causedBy(e)
                    .requestContext(request)
                    .build();
        }
    }

    /**
     * Create a POST request to create a remote document with the supplied values.
     *
     * The builder should be configured such that the rest api will only return a single document, for example
     * www.jive.come/contents/1234 would return the Jive document with an id of 1234. It is on the remote implementation
     * to correctly use the api to achieve this.
     *
     * Exact semantics for how the remote interprets the request may vary.
     *
     * @param t  The values to create the remote document with.
     * @return  The response from the server, expected to be the newly created remote document, but different rest apis may
     *          behave differently.
     * @throws HttpException  If a non 20X status code results from the request or an unchecked exception occurs.
     */
    public T post(T t) throws HttpException {
        Request request = null;
        try {
            request = Requests.requestBuilder(route(), headers)
                    .post(requestBody(t))
                    .build();
            Response response = doRequest(request);
            return parseResponse(response);
        } catch(HttpException e){
            e.request(Optional.of(request));
            throw e;
        } catch(Exception e){
            throw new HttpException.Builder()
                    .withMessage("Unexpected error executing POST")
                    .causedBy(e)
                    .requestContext(request)
                    .build();
        }

    }

    /**
     * Create a DELETE request to delete a remote document.
     *
     * The builder should be configured such that the rest api will only return a single document, for example
     * www.jive.come/contents/1234 would return the Jive document with an id of 1234. It is on the remote implementation
     * to correctly use the api to achieve this.
     *
     * Exact semantics for how the remote interprets the request may vary.
     *
     * @throws HttpException  If a non 20X status code results from the request or an unchecked exception occurs.
     */
    public void delete() throws HttpException {
        Request request = null;
        try {
            request = Requests.requestBuilder(route(), headers).delete().build();
            doRequest(request);
        } catch(HttpException e){
            e.request(Optional.ofNullable(request));
            throw e;
        } catch(Exception e){
            throw new HttpException.Builder()
                    .withMessage("Unexpected error executing DELETE")
                    .causedBy(e)
                    .requestContext(request)
                    .build();
        }
    }

    /**
     * Convert the given POJO into a json object to use in a request body.
     *
     * @param t  The object to use in the request body
     * @return  {@link RequestBody} to be added to the request
     * @throws HttpException  Wrapper for any exception that occurs creating the request body
     */
    protected RequestBody requestBody(T t) throws HttpException {
        try {
            return RequestBody.create(JSON, Jackson.jsonMapper().writeValueAsString(t));
        } catch (Exception e) {
            throw new HttpException.Builder()
                    .withMessage("Unable to parse content")
                    .causedBy(e)
                    .build();
        }
    }

    /**
     * Convert a response into a POJO
     *
     * @param response  The remote reponse to parse
     * @return  The parsed POJO
     * @throws HttpException  Wrapper for any exception that occurs creating the request body
     */
    protected T parseResponse(Response response) throws HttpException {
        try {
            if(response.body().contentLength() > 0) {
                return Jackson.jsonMapper().readValue(response.body().charStream(), entity);
            } else {
                return Jackson.jsonMapper().readValue("{}", entity);
            }
        } catch (Exception e) {
            log.warn("Cannot parse response content", e);
            throw new HttpException.Builder()
                    .withMessage("Cannot parse response content")
                    .responseContext(response)
                    .causedBy(e)
                    .build();
        }
    }

    /**
     * Turn the builder arguments into an {@link HttpUrl}
     *
     * @return  {@link HttpUrl}
     */
    protected HttpUrl route(){
        return Requests.routeBuilder(baseUrl, pathSegments, queryParameters).build();
    }

    protected OkHttpClient client(){
        return new OkHttpClient.Builder()
                .connectTimeout(requestTimeout, TimeUnit.SECONDS)
                .writeTimeout(requestTimeout, TimeUnit.SECONDS)
                .readTimeout(requestTimeout, TimeUnit.SECONDS)
                .build();
    }

    /**
     * Perform the request with the remote api, adding some standard error checking.
     *
     * @param request  The request to perform.
     * @return  Response from the remote api
     * @throws HttpException  If a non 20X status code results from the request or an unchecked exception occurs.
     */
    protected Response doRequest(Request request) throws HttpException {
        log.debug(request.toString());
        if(request.body() != null) {
            try {
                log.debug(Requests.bodyToString(request));
            } catch (IOException e) {}
        }

        Call call = client().newCall(request);

        Response response = null;
        try {
            response = call.execute();
            log.debug(response.toString());
        } catch (IOException e) {
            log.debug("Request error", e);
            throw new HttpException.Builder()
                    .withMessage(String.format("Unable to make request %s %s", request.method(), request.url().toString()))
                    .causedBy(e)
                    .requestContext(request)
                    .responseContext(response)
                    .build();
        }

        if((allowableStatusCodes.isEmpty() && response.isSuccessful()) ||
                allowableStatusCodes.contains(response.code())){
            return response;
        } else{
            String expected = allowableStatusCodes.isEmpty() ? "20X" : StringUtils.join(allowableStatusCodes, ",");
            throw new HttpException.Builder()
                    .withMessage(String.format("%s %s - %d (excepted one of [%s])",
                            request.method(), request.url().toString(), response.code(), expected))
                    .responseContext(response)
                    .requestContext(request)
                    .build();
        }
    }

    /**
     * Iterable class used for pagination.
     *
     * @see RemoteDocumentIterator
     * @param <T>  POJO representing the remote document for both requests and responses
     */
    public static class RemoteDocumentPage<T> implements Iterable<T> {
        private RemoteDocumentBuilder requestBuilder;

        /**
         * Constructor that takes a {@link RemoteDocumentBuilder} to create paged requests.
         *
         * @param requestBuilder  Builder used by iterator to build paged requests
         */
        protected RemoteDocumentPage(RemoteDocumentBuilder requestBuilder){
            this.requestBuilder = requestBuilder;
        }

        /**
         * @see RemoteDocumentIterator
         * @return  Iterator that manages paging for caller
         */
        @Override
        public Iterator<T> iterator() {
            return new RemoteDocumentIterator(requestBuilder);
        }

        /**
         * Return the first result from the paged request.
         *
         * @return  The first object in the paged request or null if there are no results.
         * @throws HttpException  If a non 20X status code results from the request or an unchecked exception occurs.
         */
        public T first() throws HttpException {
            Iterator<T> iterator = iterator();
            try{
                if(iterator.hasNext()){
                    return iterator.next();
                } else{
                    return null;
                }
            } catch(Exception e){
                if(e.getCause() instanceof HttpException){
                    throw (HttpException)e.getCause();
                }
                throw e;
            }
        }
    }

    /**
     * Class implementing {@link Iterator} to abstract paged requests for the caller.
     *
     * Allows you to iterate over a list of remote documents matching a request query in a simple for loop
     * <pre>
     * {@code
     * class Foo{
     *      Integer id;
     *      String name;
     *      String address;
     * }
     * RemoteDocument<Foo> remoteFoo = RemoteDocument.<Foo>builder()
     *      .baseUrl("www.google.com")
     *      .pathSegment("foo")
     *      .queryParameter("filter", "author(\"foo\")")
     *      .build()
     *
     * for(Foo foo : remoteFoo.getPaged()){
     *     //do stuff with foo
     * }
     * }
     * </pre>
     *
     * @param <T>
     */
    static class RemoteDocumentIterator<T> implements Iterator<T>{

        private RemoteDocumentBuilder requestBuilder;
        private Iterator<T> current;
        private Integer pageSize = 25;
        private Integer offset = 0;
        private boolean finished = false;

        /**
         * Constructor that takes a {@link RemoteDocumentBuilder} to create paged requests.
         *
         * @param requestBuilder  Builder used by iterator to build paged requests
         */
        protected RemoteDocumentIterator(RemoteDocumentBuilder requestBuilder){
            this.requestBuilder = requestBuilder;
        }

        /**
         * Determines if there are any more remote documents to iterate over.
         *
         * Responsible for making the actual request to the remote api to prime the current page, whether this is the first
         * request of if the current page has been exhausted. Will make n+1 requests to the remote where n is the number
         * of pages available.
         *
         * @see #nextPage()
         *
         * @return
         */
        @Override
        public boolean hasNext() {
            if(current == null || (!current.hasNext() && !finished)){
                current = nextPage();
            }
            return current.hasNext();
        }

        /**
         * Return the nexted parsed remote document from the local cache.
         *
         * @return  Parsed POJO representing the remote document.
         */
        @Override
        public T next() {
            return current.next();
        }

        /**
         * Make a request to the remote for a page of documents.
         *
         * Relies on several fields from the {@link RemoteDocumentBuilder}:
         * <ul>
         *     <li>{@link RemoteDocumentBuilder#pageSizeRequestParameter} - name of the query parameter used by the remote
         *     api to specify number of items per page</li>
         *     <li>{@link RemoteDocumentBuilder#pageOffsetRequestParameter} - name of the query parameter used by the remote
         *     api to specify page offset</li>
         *     <li>{@link RemoteDocumentBuilder#pageListResponseField} - what field in the response contains the T entities
         *     to parse</li>
         * </ul>
         *
         * @return  {@link Iterator} for the next page of data
         * @throws RuntimeException  Wrapper for any exceptions that occur making the request or processing results. The
         *                           {@link Iterator} interface wont let us throw checked exceptions.
         */
        protected Iterator<T> nextPage() {
            RemoteDocument remoteDocument = requestBuilder
                    .queryParameter(requestBuilder.pageSizeRequestParameter, pageSize.toString())
                    .queryParameter(requestBuilder.pageOffsetRequestParameter, offset.toString())
                    .build();
            Request request = null;
            try {
                request = Requests.requestBuilder(remoteDocument.route(), remoteDocument.headers).get().build();
                Response response = remoteDocument.doRequest(request);

                JsonFactory factory = new JsonFactory();
                ObjectMapper mapper = new ObjectMapper(factory);
                JsonNode rootNode = mapper.readTree(response.body().charStream());
                JsonNode results = rootNode.get(remoteDocument.pageListResponseField);
                JavaType type = mapper.getTypeFactory().constructCollectionType(List.class, remoteDocument.entity);
                List<T> parsedResults = Jackson.jsonMapper().readValue(results.toString(), type);
                offset += pageSize;
                if(parsedResults.size() == 0){
                    finished = true;
                }
                return parsedResults.iterator();
            } catch(HttpException e){
                finished = true;
                e.request(Optional.of(request));
                throw new RuntimeException(e);
            } catch(Exception e){
                finished = true;
                throw new RuntimeException(new HttpException.Builder()
                        .withMessage("Unexpected error fetching next page from remote")
                        .causedBy(e)
                        .requestContext(request)
                        .build());
            }
        }
    }
}