com.heroiclabs.sdk.android.HttpClient.java Source code

Java tutorial

Introduction

Here is the source code for com.heroiclabs.sdk.android.HttpClient.java

Source

/*
 * Copyright 2016 Heroic Labs
 *
 * Licensed 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.
 */
package com.heroiclabs.sdk.android;

import android.os.Build;

import com.heroiclabs.sdk.android.entity.ErrorDetails;
import com.heroiclabs.sdk.android.request.Request;
import com.heroiclabs.sdk.android.response.ErrorResponse;
import com.heroiclabs.sdk.android.response.SuccessResponse;
import com.heroiclabs.sdk.android.response.Response;
import com.heroiclabs.sdk.android.util.Codec;
import com.heroiclabs.sdk.android.util.http.GzipRequestInterceptor;
import com.heroiclabs.sdk.android.util.http.RetryInterceptor;
import com.heroiclabs.sdk.android.util.json.JsonCodec;
import com.squareup.okhttp.Callback;
import com.squareup.okhttp.HttpUrl;
import com.squareup.okhttp.MediaType;
import com.squareup.okhttp.OkHttpClient;
import com.squareup.okhttp.RequestBody;
import com.stumbleupon.async.Deferred;

import java.io.IOException;
import java.net.HttpURLConnection;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import lombok.AccessLevel;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import okio.ByteString;

/**
 * Client implementation over HTTP transport.
 */
@Slf4j
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class HttpClient implements Client {

    /** Client user agent string to use. */
    private static final String USER_AGENT = "heroiclabs-sdk-android/" + BuildConfig.VERSION_NAME + " (Android "
            + Build.VERSION.SDK_INT + "; " + System.getProperty("http.agent") + ")";

    /** API key used in all requests made by this client. */
    private final String apiKey;

    /** Accounts server URL. */
    private final String accountsServer;
    /** API server URL. */
    private final String apiServer;

    /** Codec implementation used to serialize request payloads and deserialize response entities. */
    private final Codec codec;
    /** Internal HTTP client instance. Controls request parallelism. */
    private final OkHttpClient client;

    /**
     * Default maximum number of times to attempt requests, used if a particular request
     * does not specify its own maximum number of attempts.
     */
    private final int maxAttempts;
    /** true if request data should be transparently compressed, false otherwise. */
    private final boolean compressRequests;

    /** {@inheritDoc} */
    public <T> Deferred<Response<T>> execute(final @NonNull Request<T> request) {
        final Deferred<Response<T>> deferred = new Deferred<>();

        // Select the host, implicitly only allow HTTPS.
        HttpUrl.Builder urlBuilder = new HttpUrl.Builder().scheme("https")
                .host(getServer(request.getDestination()));

        // Encode and add the path elements.
        for (final String p : getPath(request.getRequestType(), request.getIdentifiers())) {
            urlBuilder = urlBuilder.addPathSegment(p);
        }

        // Encode and add the query parameters, toString() values as we go.
        for (final Map.Entry<String, ?> e : request.getParameters().entrySet()) {
            urlBuilder = urlBuilder.addQueryParameter(e.getKey(), e.getValue().toString());
        }
        final HttpUrl url = urlBuilder.build();

        final String token = request.getSession() == null ? "" : request.getSession().getToken();
        final String authorization = "Basic " + ByteString.of((apiKey + ":" + token).getBytes()).base64();

        final String contentType = "application/json";
        final String body = getBody(request.getRequestType(), request.getEntity());

        // Construct the HTTP request.
        final com.squareup.okhttp.Request httpRequest = new com.squareup.okhttp.Request.Builder().url(url)
                .method(getMethod(request.getRequestType()),
                        body == null ? null : RequestBody.create(MediaType.parse(contentType), body))
                .header("User-Agent", USER_AGENT).header("Content-Type", contentType).header("Accept", contentType)
                .header("Authorization", authorization).build();

        // Prepare a HTTP client instance to execute against.
        final OkHttpClient client = this.client.clone();

        // Interceptors fire in the order they're declared.
        // Note: Compress first, so we don't re-compress for retried requests.
        if (this.compressRequests) {
            client.interceptors().add(GzipRequestInterceptor.INSTANCE);
        }
        final int maxAttempts = request.getMaxAttempts() < 1 ? this.maxAttempts : request.getMaxAttempts();
        client.interceptors().add(new RetryInterceptor(maxAttempts));

        // Log the outgoing request.
        if (log.isDebugEnabled()) {
            log.debug("Request: Method{" + httpRequest.method() + "} URL{" + httpRequest.urlString() + "} Headers{"
                    + httpRequest.headers().toString() + "} Body{" + body + "}");
        }

        // Send the request and retrieve a response.
        client.newCall(httpRequest).enqueue(new Callback() {
            @Override
            public void onFailure(final com.squareup.okhttp.Request httpRequest, final IOException e) {
                // Log the request failure reason.
                if (log.isDebugEnabled()) {
                    log.debug("Request Failed", e);
                }

                deferred.callback(new ErrorResponse(e.getMessage(), e, request));
            }

            @Override
            public void onResponse(final @NonNull com.squareup.okhttp.Response httpResponse) throws IOException {
                switch (httpResponse.code()) {
                // Good response with body.
                case HttpURLConnection.HTTP_OK:
                    final String responseBody = httpResponse.body().string();

                    // Log the incoming response.
                    if (log.isDebugEnabled()) {
                        log.debug("Response Success: Method{" + httpResponse.request().method() + "} URL{"
                                + httpResponse.request().urlString() + "} Code{" + httpResponse.code()
                                + "} Message{" + httpResponse.message() + "} Headers:{"
                                + httpResponse.headers().toString() + "} Body{" + responseBody + "}");
                    }

                    final T entity = codec.deserialize(responseBody, request.getResponseType());
                    deferred.callback(new SuccessResponse<>(request, httpResponse.code(), responseBody, entity));
                    break;

                // Good response, no body.
                case HttpURLConnection.HTTP_NO_CONTENT:
                case HttpURLConnection.HTTP_CREATED:
                    // Log the incoming response.
                    if (log.isDebugEnabled()) {
                        log.debug("Response Success: Method{" + httpResponse.request().method() + "} URL{"
                                + httpResponse.request().urlString() + "} Code{" + httpResponse.code()
                                + "} Message{" + httpResponse.message() + "} Headers:{"
                                + httpResponse.headers().toString() + "}");
                    }

                    deferred.callback(new SuccessResponse<>(request, httpResponse.code(), null, null));
                    break;

                // Error response.
                default:
                    final String errorBody = httpResponse.body().string();

                    // Log the incoming response.
                    if (log.isDebugEnabled()) {
                        log.debug("Response Error: Method{" + httpResponse.request().method() + "} URL{"
                                + httpResponse.request().urlString() + "} Code{" + httpResponse.code()
                                + "} Message{" + httpResponse.message() + "} Headers:{"
                                + httpResponse.headers().toString() + "} Body{" + errorBody + "}");
                    }

                    @SuppressWarnings("ThrowableResultOfMethodCallIgnored")
                    final ErrorDetails error = errorBody.isEmpty()
                            ? new ErrorDetails(httpResponse.code(),
                                    httpResponse.message() == null ? "unknown" : httpResponse.message(), null)
                            : codec.deserialize(errorBody, ErrorDetails.class);
                    deferred.callback(new ErrorResponse(error.getMessage(), error, request));
                }

                // Indicate that application-layer response processing is complete.
                httpResponse.body().close();
            }
        });

        return deferred;
    }

    //
    // Request construction helpers.
    //

    /**
     * Helper to choose between server URLs based on the Destination enum value specified by each request.
     *
     * @param destination A Destination enum value.
     * @return A server URL String.
     */
    private String getServer(final @NonNull Destination destination) {
        switch (destination) {
        case API:
            return apiServer;
        case ACCOUNTS:
            return accountsServer;
        default:
            throw new UnsupportedOperationException("Unsupported destination type");
        }
    }

    /**
     * Helper to determine the URL for a given request type, taking into account identifiers that may be supplied
     * from each request instance.
     *
     * @param type The Type enum value supplied by the request.
     * @param identifiers Any identifiers supplied by the request.
     * @return A List<String> containing the correct path segments for the given request type and identifiers.
     */
    private List<String> getPath(final @NonNull Request.Type type, final @NonNull Map<String, ?> identifiers) {
        switch (type) {
        case PING:
            return Collections.singletonList("v0");
        case SERVER:
            return Arrays.asList("v0", "server");
        case GAME:
            return Arrays.asList("v0", "game");
        case ACHIEVEMENT_LIST:
            return Arrays.asList("v0", identifiers.get("scope").toString(), "achievement");
        case ACHIEVEMENT_UPDATE:
            return Arrays.asList("v0", "gamer", "achievement", identifiers.get("achievementId").toString());
        case LEADERBOARD_LIST:
            return Arrays.asList("v0", "game", "leaderboard");
        case LEADERBOARD_GET:
            return Arrays.asList("v0", "game", "leaderboard", identifiers.get("leaderboardId").toString());
        case LEADERBOARD_RANK_GET:
            return Arrays.asList("v0", "gamer", "leaderboard", identifiers.get("leaderboardId").toString());
        case LEADERBOARD_UPDATE:
            return Arrays.asList("v0", "gamer", "leaderboard", identifiers.get("leaderboardId").toString());
        case DATASTORE_QUERY:
            return Arrays.asList("v0", "datastore", identifiers.get("table").toString());
        case DATASTORE_GET:
            if (identifiers.containsKey("owner")) {
                return Arrays.asList("v0", "datastore", identifiers.get("table").toString(),
                        identifiers.get("key").toString(), identifiers.get("owner").toString());
            } else {
                return Arrays.asList("v0", "datastore", identifiers.get("table").toString(),
                        identifiers.get("key").toString());
            }
        case DATASTORE_PUT:
        case DATASTORE_PATCH:
        case DATASTORE_DELETE:
            return Arrays.asList("v0", "datastore", identifiers.get("table").toString(),
                    identifiers.get("key").toString());
        case CLOUD_STORAGE_GET:
        case CLOUD_STORAGE_PUT:
        case CLOUD_STORAGE_DELETE:
            return Arrays.asList("v0", "gamer", "storage", identifiers.get("key").toString());
        case SHARED_STORAGE_QUERY:
            return Arrays.asList("v0", "gamer", "shared");
        case SHARED_STORAGE_GET:
            return Arrays.asList("v0", "gamer", "shared", identifiers.get("key").toString());
        case SHARED_STORAGE_PUBLIC_PUT:
        case SHARED_STORAGE_PUBLIC_PATCH:
        case SHARED_STORAGE_PUBLIC_DELETE:
            return Arrays.asList("v0", "gamer", "shared", identifiers.get("key").toString(), "public");
        case MATCH_LIST:
        case MATCHMAKING:
        case MATCH_CREATE:
            return Arrays.asList("v0", "gamer", "match");
        case MATCH_GET:
        case MATCH_ACTION:
            return Arrays.asList("v0", "gamer", "match", identifiers.get("matchId").toString());
        case MATCH_TURN_LIST:
            return Arrays.asList("v0", "gamer", "match", identifiers.get("matchId").toString(), "turn",
                    identifiers.get("turnId").toString());
        case MATCH_TURN_SUBMIT:
            return Arrays.asList("v0", "gamer", "match", identifiers.get("matchId").toString(), "turn");
        case MATCH_UPDATES:
            return Arrays.asList("v0", "gamer", "matches");
        case MESSAGE_LIST:
            return Arrays.asList("v0", "gamer", "message");
        case MESSAGE_READ:
        case MESSAGE_DELETE:
            return Arrays.asList("v0", "gamer", "message", identifiers.get("messageId").toString());
        case SCRIPT:
            return Arrays.asList("v0", "game", "script", identifiers.get("scriptId").toString());
        case PURCHASE_VERIFICATION:
            return Arrays.asList("v0", "gamer", "purchase", "verify", "google");
        case GAMER_GET:
        case GAMER_UPDATE:
            return Arrays.asList("v0", "gamer");
        case CHECK_ANONYMOUS:
            return Arrays.asList("v0", "gamer", "check", "anonymous");
        case CHECK_EMAIL:
            return Arrays.asList("v0", "gamer", "check", "email");
        case CHECK_FACEBOOK:
            return Arrays.asList("v0", "gamer", "check", "facebook");
        case CHECK_GOOGLE:
            return Arrays.asList("v0", "gamer", "check", "google");
        case CHECK_TANGO:
            return Arrays.asList("v0", "gamer", "check", "tango");
        case CREATE_EMAIL:
            return Arrays.asList("v0", "gamer", "account", "email", "create");
        case SEND_RESET_EMAIL:
            return Arrays.asList("v0", "gamer", "account", "email", "reset", "send");
        case LINK_ANONYMOUS:
            return Arrays.asList("v0", "gamer", "link", "anonymous");
        case LINK_FACEBOOK:
            return Arrays.asList("v0", "gamer", "link", "facebook");
        case LINK_GOOGLE:
            return Arrays.asList("v0", "gamer", "link", "google");
        case LINK_TANGO:
            return Arrays.asList("v0", "gamer", "link", "tango");
        case LOGIN_ANONYMOUS:
            return Arrays.asList("v0", "gamer", "login", "anonymous");
        case LOGIN_EMAIL:
            return Arrays.asList("v0", "gamer", "login", "email");
        case LOGIN_FACEBOOK:
            return Arrays.asList("v0", "gamer", "login", "facebook");
        case LOGIN_GOOGLE:
            return Arrays.asList("v0", "gamer", "login", "google");
        case LOGIN_TANGO:
            return Arrays.asList("v0", "gamer", "login", "tango");
        case UNLINK_ANONYMOUS:
            return Arrays.asList("v0", "gamer", "unlink", "anonymous");
        case UNLINK_EMAIL:
            return Arrays.asList("v0", "gamer", "unlink", "email");
        case UNLINK_FACEBOOK:
            return Arrays.asList("v0", "gamer", "unlink", "facebook");
        case UNLINK_GOOGLE:
            return Arrays.asList("v0", "gamer", "unlink", "google");
        case UNLINK_TANGO:
            return Arrays.asList("v0", "gamer", "unlink", "tango");
        default:
            throw new UnsupportedOperationException("Unsupported request type");
        }
    }

    /**
     * Helper to select a HTTP method String based on Request type.
     *
     * @param type A Request.Type enum value.
     * @return A corresponding String containing a HTTP method name.
     */
    private String getMethod(final @NonNull Request.Type type) {
        switch (type) {
        case PING:
        case SERVER:
        case GAME:
        case GAMER_GET:
        case ACHIEVEMENT_LIST:
        case LEADERBOARD_LIST:
        case LEADERBOARD_GET:
        case LEADERBOARD_RANK_GET:
        case DATASTORE_QUERY:
        case DATASTORE_GET:
        case CLOUD_STORAGE_GET:
        case SHARED_STORAGE_QUERY:
        case SHARED_STORAGE_GET:
        case MATCH_LIST:
        case MATCH_GET:
        case MATCH_TURN_LIST:
        case MATCH_UPDATES:
        case MESSAGE_LIST:
        case MESSAGE_READ:
            return "GET";
        case ACHIEVEMENT_UPDATE:
        case LEADERBOARD_UPDATE:
        case MATCHMAKING:
        case MATCH_ACTION:
        case MATCH_TURN_SUBMIT:
        case SCRIPT:
        case PURCHASE_VERIFICATION:
        case GAMER_UPDATE:
        case CHECK_ANONYMOUS:
        case CHECK_EMAIL:
        case CHECK_FACEBOOK:
        case CHECK_GOOGLE:
        case CHECK_TANGO:
        case CREATE_EMAIL:
        case SEND_RESET_EMAIL:
        case LINK_ANONYMOUS:
        case LINK_FACEBOOK:
        case LINK_GOOGLE:
        case LINK_TANGO:
        case LOGIN_ANONYMOUS:
        case LOGIN_EMAIL:
        case LOGIN_FACEBOOK:
        case LOGIN_GOOGLE:
        case LOGIN_TANGO:
        case UNLINK_ANONYMOUS:
        case UNLINK_EMAIL:
        case UNLINK_FACEBOOK:
        case UNLINK_GOOGLE:
        case UNLINK_TANGO:
            return "POST";
        case DATASTORE_PUT:
        case CLOUD_STORAGE_PUT:
        case SHARED_STORAGE_PUBLIC_PUT:
        case MATCH_CREATE:
            return "PUT";
        case DATASTORE_PATCH:
        case SHARED_STORAGE_PUBLIC_PATCH:
            return "PATCH";
        case DATASTORE_DELETE:
        case CLOUD_STORAGE_DELETE:
        case SHARED_STORAGE_PUBLIC_DELETE:
        case MESSAGE_DELETE:
            return "DELETE";
        default:
            throw new UnsupportedOperationException("Unsupported request type");
        }
    }

    /**
     * Helper to convert a set of entity values supplied by a request to an HTTP request body, based on
     * the Request.Type of that request.
     *
     * @param type The Request.Type enum value supplied by the request.
     * @param entity The entity parameters supplied gy the request.
     * @return A serialized request body ready to be sent through the HTTP client.
     */
    private String getBody(final @NonNull Request.Type type, final @NonNull Map<String, ?> entity) {
        switch (type) {
        case PING:
        case SERVER:
        case GAME:
        case ACHIEVEMENT_LIST:
        case LEADERBOARD_LIST:
        case LEADERBOARD_GET:
        case LEADERBOARD_RANK_GET:
        case DATASTORE_QUERY:
        case DATASTORE_GET:
        case DATASTORE_DELETE:
        case CLOUD_STORAGE_GET:
        case CLOUD_STORAGE_DELETE:
        case SHARED_STORAGE_QUERY:
        case SHARED_STORAGE_GET:
        case SHARED_STORAGE_PUBLIC_DELETE:
        case MATCH_LIST:
        case MATCH_GET:
        case MATCH_TURN_LIST:
        case MATCH_UPDATES:
        case MESSAGE_LIST:
        case MESSAGE_READ:
        case MESSAGE_DELETE:
        case GAMER_GET:
            return null;
        case CLOUD_STORAGE_PUT:
        case SHARED_STORAGE_PUBLIC_PUT:
        case SHARED_STORAGE_PUBLIC_PATCH:
            return codec.serialize(entity.get("value"));
        case MATCH_TURN_SUBMIT:
            final Map<String, Object> turnSubmission = new HashMap<>();
            turnSubmission.put("last_turn", entity.get("last_turn"));
            turnSubmission.put("next_gamer_id", entity.get("next_gamer_id"));
            turnSubmission.put("data", entity.get("data") == null ? "" : codec.serialize(entity.get("data")));
            return codec.serialize(turnSubmission);
        case SCRIPT:
            return entity.get("input") == null ? "{}" : codec.serialize(entity.get("input"));
        case DATASTORE_PUT:
        case DATASTORE_PATCH:
        case ACHIEVEMENT_UPDATE:
        case LEADERBOARD_UPDATE:
        case MATCHMAKING:
        case MATCH_CREATE:
        case MATCH_ACTION:
        case PURCHASE_VERIFICATION:
        case GAMER_UPDATE:
        case CHECK_ANONYMOUS:
        case CHECK_EMAIL:
        case CHECK_FACEBOOK:
        case CHECK_GOOGLE:
        case CHECK_TANGO:
        case CREATE_EMAIL:
        case SEND_RESET_EMAIL:
        case LINK_ANONYMOUS:
        case LINK_FACEBOOK:
        case LINK_GOOGLE:
        case LINK_TANGO:
        case LOGIN_ANONYMOUS:
        case LOGIN_EMAIL:
        case LOGIN_FACEBOOK:
        case LOGIN_GOOGLE:
        case LOGIN_TANGO:
        case UNLINK_ANONYMOUS:
        case UNLINK_EMAIL:
        case UNLINK_FACEBOOK:
        case UNLINK_GOOGLE:
        case UNLINK_TANGO:
            return codec.serialize(entity);
        default:
            throw new UnsupportedOperationException("Unsupported request type");
        }
    }

    /**
     * Build a HttpClient with default options.
     *
     * @param apiKey The API key to use, required.
     * @return A new HttpClient with default options.
     */
    public static HttpClient defaults(final @NonNull String apiKey) {
        return builder(apiKey).build();
    }

    //
    // Client builder.
    //

    /**
     * Begin creating a new HttpClient instance.
     *
     * @param apiKey The API key to use, required.
     * @return A new HttpClient.Builder instance that can be used to set optional parameters before constructing
     *         the final client instance.
     */
    public static Builder builder(final @NonNull String apiKey) {
        return new Builder(apiKey);
    }

    /**
     * A Builder object that constructs HttpClient instances.
     */
    @RequiredArgsConstructor(suppressConstructorProperties = true, access = AccessLevel.PRIVATE)
    public static class Builder {

        /** API key, required. */
        @NonNull
        private String apiKey;

        /** Default Accounts server URL. */
        private String accountsServer = "accounts.heroiclabs.com";
        /** Default API server URL. */
        private String apiServer = "api.heroiclabs.com";

        /** Codec instance to use. */
        private Codec codec = new JsonCodec();

        /** Default HTTP client instance. */
        private OkHttpClient client = new OkHttpClient();

        /** Default maximum number of request attempts. */
        private int maxAttempts = 3;
        /** By default compress requests. */
        private boolean compressRequests = true;

        /**
         * Set a different API key to the one first passed to HttpClient.builder().
         *
         * @param apiKey The API key to use, required.
         * @return The same Builder instance, can be used to chain method calls.
         */
        public Builder apiKey(final @NonNull String apiKey) {
            this.apiKey = apiKey;
            return this;
        }

        /**
         * Define a custom URL string for the Accounts server this client will use.
         *
         * @param accountsServer A String representing the desired Accounts server URL.
         * @return The same Builder instance, can be used to chain method calls.
         */
        public Builder accountsServer(final @NonNull String accountsServer) {
            this.accountsServer = accountsServer;
            return this;
        }

        /**
         * Define a custom URL string for the API server this client will use.
         *
         * @param apiServer A String representing the desired API server URL.
         * @return The same Builder instance, can be used to chain method calls.
         */
        public Builder apiServer(final @NonNull String apiServer) {
            this.apiServer = apiServer;
            return this;
        }

        /**
         * Define a custom Codec implementation instance to use when serializing request data and
         * deserializing response entities. The HttpClient requires that this Codec implementation be
         * suitable for handling JSON.
         *
         * @param codec A Codec implementation instance.
         * @return The same Builder instance, can be used to chain method calls.
         */
        public Builder codec(final @NonNull Codec codec) {
            this.codec = codec;
            return this;
        }

        /**
         * Define a custom number of maximum request attempts to use by default when individual requests do not
         * supply a value for this parameter.
         *
         * Default 3. Must be greater than or equal to 1 if supplied.
         *
         * @param maxAttempts Maximum number of request attempts to use by default.
         * @return The same Builder instance, can be used to chain method calls.
         */
        public Builder maxAttempts(final int maxAttempts) {
            if (maxAttempts < 1) {
                throw new IllegalArgumentException("'maxAttempts' must be >= 1");
            }
            this.maxAttempts = maxAttempts;
            return this;
        }

        /**
         * Toggle transparent compression of request data. Default true (enabled.)
         *
         * @param compressRequests true if request data should be compressed, false otherwise.
         * @return The same Builder instance, can be used to chain method calls.
         */
        public Builder compressRequests(final boolean compressRequests) {
            this.compressRequests = compressRequests;
            return this;
        }

        /**
         * Finalize the client build.
         *
         * @return A new HttpClient instance based on the options set in this builder.
         */
        public HttpClient build() {
            return new HttpClient(apiKey, accountsServer, apiServer, codec, client, maxAttempts, compressRequests);
        }

    }

}