Java tutorial
/* * 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); } } }