com.buffalokiwi.aerodrome.jet.JetAPI.java Source code

Java tutorial

Introduction

Here is the source code for com.buffalokiwi.aerodrome.jet.JetAPI.java

Source

/**
 * This file is part of the Aerodrome package, and is subject to the 
 * terms and conditions defined in file 'LICENSE', which is part 
 * of this source code package.
 *
 * Copyright (c) 2016 All Rights Reserved, John T. Quinn III,
 * <johnquinn3@gmail.com>
 *
 * THIS CODE AND INFORMATION ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY
 * KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A
 * PARTICULAR PURPOSE.
 */

package com.buffalokiwi.aerodrome.jet;

import static com.buffalokiwi.aerodrome.jet.JetAPIAuth.AUTH_TEST_RESPONSE;
import com.buffalokiwi.api.API;
import com.buffalokiwi.api.APIException;
import com.buffalokiwi.api.APILog;
import com.buffalokiwi.api.IAPIHttpClient;
import com.buffalokiwi.api.IAPIResponse;
import com.buffalokiwi.api.PostFile;
import com.buffalokiwi.api.ResponseCode;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
import javax.json.Json;
import javax.json.JsonArray;
import javax.json.JsonObject;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.entity.ContentType;

/**
 * Jet API
 *
 * @author John Quinn
 * 
 * @todo Authentication checks need to exist on every call.
 * If auth is expired, client needs to lock the config object and 
 * attempt reauthentication, then re-set the new credentials.
 */
public class JetAPI extends API implements IJetAPI, IJetAPIAuth {
    /**
     * The auth test response from jet 
     */
    public static final String AUTH_TEST_RESPONSE = "\"This message is authorized.\"";

    /**
     * Jet API Configuration
     */
    protected final JetConfig config;

    /**
     * A flag for if this class is attempting to reauthenticate.
     */
    private final AtomicBoolean isReauth = new AtomicBoolean(false);

    /**
     * A global rate limit callback for if any of the api methods receive a 
     * too many requests response
     */
    private final List<Consumer<IAPIResponse>> rateLimitHandlers = new CopyOnWriteArrayList<>();

    /**
     * Delay threads for this long before retrying the previous rate limited request
     */
    private static final long RATE_LIMIT_DELAY = 5000L;

    /**
     * Logger 
     */
    private static final Log LOG = LogFactory.getLog(JetAPI.class);

    private static final ReentrantLock authLock = new ReentrantLock();

    private static final AtomicInteger reauthAttempts = new AtomicInteger(0);

    private static final List<IJetErrorHandler> errorHandlers = new CopyOnWriteArrayList<>();

    /**
     * Create a new API instance
     * @param client The built APIHttpClient instance 
     * @param conf The Jet Configuration object
     */
    public JetAPI(final IAPIHttpClient client, final JetConfig conf) {
        super(client);

        if (conf == null)
            throw new IllegalArgumentException("conf cannot be null");

        config = conf;
    }

    @Override
    public void setErrorHandler(final IJetErrorHandler handler) {
        Utils.checkNull(handler, "handler");
        errorHandlers.add(handler);
    }

    @Override
    public void setRateLimitHandler(final Consumer<IAPIResponse> handler) {
        Utils.checkNull(handler, "handler");
        rateLimitHandlers.add(handler);
    }

    /**
     * Create a new API instance
     * @param client The built APIHttpClient instance 
     * @param conf The Jet Configuration object
     * @param lockHost Toggle locking the host to a domain if http is not present
     * in the url string.
     */
    public JetAPI(final IAPIHttpClient client, final JetConfig conf, final boolean lockHost) {
        super(client, lockHost);

        if (conf == null)
            throw new IllegalArgumentException("conf cannot be null");

        config = conf;
    }

    /**
     * Create a new API instance
     * @param client The built APIHttpClient instance 
     * @param conf The Jet Configuration object
     * @param lockHost Toggle locking the host to a domain if http is not present
     * in the url string.
     * @param maxDownloadSize Set a maximum download site for the local client.
     * This is a fixed limit.
     */
    public JetAPI(final IAPIHttpClient client, final JetConfig conf, final boolean lockHost,
            final long maxDownloadSize) {
        super(client, lockHost, maxDownloadSize);

        if (conf == null)
            throw new IllegalArgumentException("conf cannot be null");

        config = conf;
    }

    /**
     * Retrieve a HeaderBuilder instance with an Authorization header 
     * @return builder 
     */
    @Override
    public JetHeaderBuilder getHeaderBuilder() {
        return JetHeaderBuilder.getHeaderBuilder(config.getAuthorizationHeaderValue());
    }

    /**
     * Retrieve a headers map for use with a JSON request
     * @return JSON builder 
     */
    @Override
    public JetHeaderBuilder getJSONHeaderBuilder() {
        return JetHeaderBuilder.getJSONHeaderBuilder(config.getAuthorizationHeaderValue());
    }

    /**
     * Retrieve a headers map for use with a plain text request
     * @return plain text builder
     */
    @Override
    public JetHeaderBuilder getPlainHeaderBuilder() {
        return JetHeaderBuilder.getPlainHeaderBuilder(config.getAuthorizationHeaderValue());
    }

    /**
     * Perform a get-based request to some endpoint
     * @param url The URL
     * @param headers Extra headers to send
     * @return The response
     * @throws APIException If something goes wrong (like an IOException)
     */
    @Override
    public IJetAPIResponse get(final String url, final Map<String, String> headers)
            throws APIException, JetException {
        IAPIResponse response = null;
        try {
            response = super.get(url, headers);

            try {
                return JetAPIResponse.createFromAPIResponse(response);
            } catch (JetException e) {
                //..try again
                return get(url, processJetException(e, headers));
            }
        } catch (Exception e) {
            notifyErrorHandlers(response, e);
            throw e;
        }
    }

    private void notifyErrorHandlers(final IAPIResponse res, final Exception e) {
        for (final IJetErrorHandler handler : errorHandlers) {
            if (e instanceof JetException)
                handler.onAPIError(res, (JetException) e);
            else
                handler.onAPIError(res, e);
        }
    }

    /**
     * Send arbitrary post data to some endpoint
     * @param url URL
     * @param payload Data to send
     * @param headers Extra headers to send
     * @return response
     * @throws APIException if something goes wrong
     */
    @Override
    public IJetAPIResponse post(final String url, final String payload, final Map<String, String> headers)
            throws APIException, JetException {
        IAPIResponse response = null;
        try {
            response = super.post(url, payload, headers);
            try {
                return JetAPIResponse.createFromAPIResponse(response);
            } catch (JetException e) {
                //..try again
                return post(url, payload, processJetException(e, headers));
            }
        } catch (Exception e) {
            APILog.trace(LOG, e);
            notifyErrorHandlers(response, e);
            throw e;
        }
    }

    /**
     * Perform a put-based request to some endpoint
     * @param url URL
     * @param payload Payload to send
     * @param headers additional headers to send
     * @return response
     * @throws APIException
     */
    @Override
    public IJetAPIResponse post(final String url, final InputStream payload, final long contentLength,
            final ContentType contentType, final Map<String, String> headers) throws APIException {
        IAPIResponse response = null;
        try {
            response = super.post(url, payload, contentLength, contentType, headers);
            try {
                return JetAPIResponse.createFromAPIResponse(response);
            } catch (JetException e) {
                //..try again
                return post(url, payload, contentLength, contentType, processJetException(e, headers));
            }
        } catch (Exception e) {
            notifyErrorHandlers(response, e);
            throw e;
        }
    }

    @Override
    public IJetAPIResponse post(final String url, final PostFile file, Map<String, String> headers)
            throws APIException {
        IAPIResponse response = null;
        try {
            response = super.post(url, file, headers);
            try {
                return JetAPIResponse.createFromAPIResponse(response);
            } catch (JetException e) {
                //..try again
                return post(url, file, processJetException(e, headers));
            }
        } catch (Exception e) {
            notifyErrorHandlers(response, e);
            throw e;
        }
    }

    /**
     * Perform a put-based request to some endpoint
     * @param url URL
     * @param payload Payload to send
     * @param headers additional headers to send
     * @return response
     * @throws APIException
     */
    @Override
    public IJetAPIResponse put(final String url, final String payload, final Map<String, String> headers)
            throws APIException, JetException {
        IAPIResponse response = null;
        try {
            response = super.put(url, payload, headers);
            try {
                return JetAPIResponse.createFromAPIResponse(response);
            } catch (JetException e) {
                //..try again
                return put(url, payload, processJetException(e, headers));
            }
        } catch (Exception e) {
            notifyErrorHandlers(response, e);
            throw e;
        }
    }

    /**
     * Perform a put-based request to some endpoint
     * @param url URL
     * @param payload Payload to send
     * @param headers additional headers to send
     * @return response
     * @throws APIException
     */
    @Override
    public IJetAPIResponse put(final String url, final InputStream payload, final long contentLength,
            final ContentType contentType, final Map<String, String> headers) throws APIException, JetException {
        IAPIResponse response = null;
        try {
            response = super.put(url, payload, contentLength, contentType, headers);
            try {
                return JetAPIResponse.createFromAPIResponse(response);
            } catch (JetException e) {
                //..try again
                return put(url, payload, contentLength, contentType, processJetException(e, headers));
            }
        } catch (Exception e) {
            notifyErrorHandlers(response, e);
            throw e;
        }

    }

    @Override
    public IJetAPIResponse put(final String url, final PostFile file, Map<String, String> headers)
            throws APIException, JetException {
        IAPIResponse response = null;
        try {
            response = super.put(url, file, headers);
            try {
                return JetAPIResponse.createFromAPIResponse(response);
            } catch (JetException e) {
                //..try again
                return put(url, file, processJetException(e, headers));
            }
        } catch (Exception e) {
            notifyErrorHandlers(response, e);
            throw e;
        }

    }

    /**
     * Perform a patch-based request to some endpoint
     * @param url URL
     * @param payload Payload to send
     * @param headers additional headers to send
     * @return response
     * @throws APIException
     */
    @Override
    public IJetAPIResponse patch(final String url, final String payload, final Map<String, String> headers)
            throws APIException, JetException {
        IAPIResponse response = null;
        try {
            response = super.patch(url, payload, headers);
            try {
                return JetAPIResponse.createFromAPIResponse(response);
            } catch (JetException e) {
                //..try again
                return patch(url, payload, processJetException(e, headers));
            }
        } catch (Exception e) {
            notifyErrorHandlers(response, e);
            throw e;
        }
    }

    /**
     * Perform a patch-based request to some endpoint
     * @param url URL
     * @param payload Payload to send
     * @param headers additional headers to send
     * @return response
     * @throws APIException
     */
    @Override
    public IJetAPIResponse patch(final String url, final InputStream payload, final long contentLength,
            final ContentType contentType, final Map<String, String> headers) throws APIException, JetException {
        IAPIResponse response = null;
        try {
            response = super.patch(url, payload, contentLength, contentType, headers);
            try {
                return JetAPIResponse.createFromAPIResponse(response);
            } catch (JetException e) {
                //..try again
                return patch(url, payload, contentLength, contentType, processJetException(e, headers));
            }
        } catch (Exception e) {
            notifyErrorHandlers(response, e);
            throw e;
        }

    }

    @Override
    public IJetAPIResponse patch(final String url, final PostFile file, Map<String, String> headers)
            throws APIException, JetException {
        IAPIResponse response = null;
        try {
            response = super.patch(url, file, headers);
            try {
                return JetAPIResponse.createFromAPIResponse(response);
            } catch (JetException e) {
                //..try again
                return patch(url, file, processJetException(e, headers));
            }
        } catch (Exception e) {
            notifyErrorHandlers(response, e);
            throw e;
        }

    }

    /**
     * For JetExceptions: There might be an authorization or a rate limit 
     * issue, which can be fixed in this method.
     * This throws an exception on failure.
     * No exception is success.
     * @param e exception
     * @return modified headers 
     * @throws APIException
     * @throws JetException 
     */
    private Map<String, String> processJetException(final JetException e, final Map<String, String> headers)
            throws APIException, JetException {
        final IAPIResponse response = e.getResponse();
        if (response == null)
            throw e;
        else if (response.getStatusLine().getStatusCode() == JetAPIResponse.ResponseCode.UNAUTHORIZED.getCode()) {
            return tryLogin(e, headers);
        } else if (e.getResponse().getStatusLine().getStatusCode() == ResponseCode.TOO_MANY_REQUESTS.getCode()) {
            return doRateLimit(e, headers);
        } else
            throw e;
    }

    /**
     * Call any rate limit handlers 
     * @param e
     * @param headers
     * @return
     * @throws JetException 
     */
    private Map<String, String> doRateLimit(final JetException e, final Map<String, String> headers)
            throws JetException {
        try {
            rateLimitHandlers.forEach(c -> c.accept(e.getResponse()));
            Thread.sleep(RATE_LIMIT_DELAY);
            return headers;
        } catch (InterruptedException ex) {
            throw e;
        }
    }

    /**
     * Attempt to log in.
     * @param e
     * @param headers
     * @return
     * @throws JetException
     * @throws APIException 
     */
    private Map<String, String> tryLogin(final JetException e, final Map<String, String> headers)
            throws JetException, APIException {
        if (reauthAttempts.get() >= 5) {
            //..This should be considered a fatal exception
            throw new JetException("5 attempts to reauthenticate have failed; I'm not going to try again.", e);
        }

        reauthAttempts.incrementAndGet();

        try {
            //..Try to re-authenticate
            login();

            //..success
            reauthAttempts.set(0);

            Map<String, String> newHeaders = new HashMap<>(headers);

            //..Add the new auth header for this request 
            newHeaders.put("Authorization", config.getAuthorizationHeaderValue());

            return Collections.unmodifiableMap(newHeaders);
        } catch (JetAuthException authE) {
            throw new JetException("Failed to reauthenticate", authE);
        }
    }

    /**
     * Attempt to log in to the Jet API, and retrieve a token
     * @return If the user is now logged in and the token has been acquired
     * @throws APIException if something goes wrong
     * @throws JetException if there are errors in the API response body
     * @throws JetAuthException if there is a problem with the authentication
     * data in the configuration object after setting it from the login response.
     */
    @Override
    public boolean login() throws APIException, JetException, JetAuthException {
        //..Send the authorization request and attempt to set the response data in 
        //  the config cache.
        APILog.info(LOG, "Attempting Login...");

        try {
            setConfigurationDataFromLogin(post(config.getAuthenticationURL(), getLoginPayload().toString(),
                    JetHeaderBuilder.getJSONHeaderBuilder("").build()));
        } catch (JetException e) {
            APILog.info(LOG, "Failed to authenticate :-( ");
            APILog.info(LOG, "A \"Bad Request\" response from Jet typically means bad credentials");
            throw e;
        }

        //..Test the new configuration data from the response 
        config.testConfigurationData();

        APILog.info(LOG, "Jet seems to like those credentials. Testing authentication...");

        //..Perform a live authorization test
        if (!authTest())
            config.clearAuthenticationData();

        //..Return the auth state
        if (config.isAuthenticated()) {
            APILog.info(LOG, "Success!  You're logged in.");

            return true;
        }

        return false;
    }

    /**
     * Retrieve the payload for the login/authentication request.
     * This creates an object with "user" and "pass" properties with values 
     * from the current JetConfig object.
     * @return The built JSON
     */
    private JsonObject getLoginPayload() {
        return Json.createObjectBuilder().add("user", config.getUsername()).add("pass", config.getPassword())
                .build();
    }

    /**
     * Sets the configuration data from an authentication request response
     * @param response Response from login()
     * @throws JetException if the response does not contain 
     * id_token, token_type or expires_on 
     * @see JetAPI#login() 
     */
    private void setConfigurationDataFromLogin(final IJetAPIResponse response) throws JetException {
        //..Turn it into JSON
        final JsonObject res = response.getJsonObject();

        try {
            //..Set the authentication data
            config.setAuthenticationData(res.getString("id_token"), res.getString("token_type"),
                    res.getString("expires_on"));
        } catch (NullPointerException e) {
            throw new JetException("Authentication response is missing id_token, token_type or "
                    + "expires_on. Check authentication response", e);
        }
    }

    /**
     *
     * @return If the authorization test was successful
     * @throws APIException if there's a problem
     */
    private boolean authTest() throws APIException {
        return get(config.getAuthTestURL(), getPlainHeaderBuilder().build()).getResponseContent()
                .equals(AUTH_TEST_RESPONSE);
    }

    public void getFulfillmentNodes() throws Exception {
        IJetAPIResponse res = get("/setup/fulfillmentNodes", getJSONHeaderBuilder().build());
        System.out.println(res);
    }

    public void getReturnNodes() throws Exception {
        IJetAPIResponse res = get("/setup/returnsLocations", getJSONHeaderBuilder().build());
        System.out.println(res);
    }

    /**
     * Execute a HttpRequest
     * @param hr request
     * @return response
     * @throws APIException If the request failed
     */
    @Override
    protected IAPIResponse executeRequest(final HttpUriRequest hr) throws APIException {
        if (!config.isAuthenticated())
            checkAuth(hr);

        return super.executeRequest(hr);
    }

    /**
     * Checks the authentication state, and if it needs to be authenticated, this
     * locks the api and authenticates. 
     * @param hr
     * @throws APIException 
     */
    private void checkAuth(final HttpUriRequest hr) throws APIException {
        try {
            if (!authLock.isHeldByCurrentThread() && authLock.tryLock(1000L, TimeUnit.MILLISECONDS)
                    && !isReauth.get()) {
                APILog.debug(LOG,
                        "Thread " + Thread.currentThread().getName() + " obtained the authentication lock");
                try {
                    if (!config.isAuthenticated()) {
                        performReauth(hr);
                    }
                } finally {
                    try {
                        APILog.debug(LOG, "Thread " + Thread.currentThread().getName()
                                + " has released the authentication lock");
                        authLock.unlock();
                    } catch (Exception e) {
                        APILog.debug(LOG, e, "Thread " + Thread.currentThread().getName()
                                + " Caused an exception while trying to release it's lock");
                    }
                }
            } else {
                if (!authLock.isHeldByCurrentThread())
                    APILog.debug(LOG,
                            "Thread " + Thread.currentThread().getName() + " is waiting for authentication");

                int ct = 0;
                while (!authLock.isHeldByCurrentThread() && !config.isAuthenticated() && !isReauth.get()) {
                    Thread.sleep(1000L);
                }

                hr.setHeader("Authorization", config.getAuthorizationHeaderValue());
            }

        } catch (InterruptedException e) {
            //..do nothing 
        } catch (Exception e) {
            throw e;
        }
    }

    /**
     * Make the auth calls.
     * If successul, the new auth header is added to hr.
     * @param hr request
     * @throws APIException 
     */
    private void performReauth(final HttpUriRequest hr) throws APIException {
        isReauth.set(true);
        try {
            login();
            hr.setHeader("Authorization", config.getAuthorizationHeaderValue());
        } catch (JetAuthException e) {
            APILog.error(LOG, "Failed to reauthenticate");
        } finally {
            isReauth.set(false);
        }
    }

    /**
     * Turn a jet api response into a list of tokens 
     * @param a Json array 
     * @param includePath Toggle including the entire uri or only the rightmost
     * part.
     * @return tokens 
     */
    protected List<String> jsonArrayToTokenList(final JsonArray a, final boolean includePath) {
        final List<String> out = new ArrayList<>();

        if (a != null) {
            for (int i = 0; i < a.size(); i++) {
                out.add(processTokenPath(a.getString(i, ""), includePath));
            }
        }

        return out;
    }

    /**
     * This will either strip or leave the path on an order status uri.
     * If includePath is true, only the rightmost path entry is returned.
     * @param path uri
     * @param includePath toggle 
     * @return path 
     */
    private String processTokenPath(final String path, final boolean includePath) {
        if (!includePath) {
            final String[] parts = path.split("/");
            if (parts.length > 0)
                return parts[parts.length - 1];
        }

        return path;
    }
}