/* * Copyright 2014 Google Inc. All rights reserved. * * * 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.google.maps; import com.google.gson.FieldNamingPolicy; import com.google.maps.errors.ApiException; import com.google.maps.errors.OverQueryLimitException; import com.google.maps.internal.ApiConfig; import com.google.maps.internal.ApiResponse; import com.google.maps.internal.ExceptionsAllowedToRetry; import com.google.maps.internal.UrlSigner; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.UnsupportedEncodingException; import java.net.Proxy; import java.net.URLEncoder; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.Map; import java.util.concurrent.TimeUnit; /** * The entry point for making requests against the Google Geo APIs. */ public class GeoApiContext { private static final Logger LOG = LoggerFactory.getLogger(GeoApiContext.class); private static final String VERSION = "@VERSION@"; // Populated by the build script private static final String USER_AGENT = "GoogleGeoApiClientJava/" + VERSION; private static final int DEFAULT_BACKOFF_TIMEOUT_MILLIS = 60 * 1000; // 60s private String baseUrlOverride; private String apiKey; private String clientId; private UrlSigner urlSigner; private String channel; private RequestHandler requestHandler; private Integer maxRetries; private ExceptionsAllowedToRetry exceptionsAllowedToRetry = new ExceptionsAllowedToRetry(); /** * RequestHandler is the service provider interface that enables requests to be handled via * switchable back ends. There are supplied implementations of this interface for both * OkHttp and Google App Engine's URL Fetch API. * * @see OkHttpRequestHandler * @see GaeRequestHandler */ public interface RequestHandler { <T, R extends ApiResponse<T>> PendingResult<T> handle(String hostName, String url, String userAgent, Class<R> clazz, FieldNamingPolicy fieldNamingPolicy, long errorTimeout, Integer maxRetries, ExceptionsAllowedToRetry exceptionsAllowedToRetry); <T, R extends ApiResponse<T>> PendingResult<T> handlePost(String hostName, String url, String payload, String userAgent, Class<R> clazz, FieldNamingPolicy fieldNamingPolicy, long errorTimeout, Integer maxRetries, ExceptionsAllowedToRetry exceptionsAllowedToRetry); void setConnectTimeout(long timeout, TimeUnit unit); void setReadTimeout(long timeout, TimeUnit unit); void setWriteTimeout(long timeout, TimeUnit unit); void setQueriesPerSecond(int maxQps); void setQueriesPerSecond(int maxQps, int minimumInterval); void setProxy(Proxy proxy); } private long errorTimeout = DEFAULT_BACKOFF_TIMEOUT_MILLIS; /** * Construct a GeoApiContext with OkHttp. */ public GeoApiContext() { this(new OkHttpRequestHandler()); } /** * Construct a GeoApiContext with the specified strategy for handling requests. * * @see OkHttpRequestHandler * @see GaeRequestHandler * * @param requestHandler How to handle URL requests to the Google Maps APIs. */ public GeoApiContext(RequestHandler requestHandler) { this.requestHandler = requestHandler; this.exceptionsAllowedToRetry.add(OverQueryLimitException.class); } <T, R extends ApiResponse<T>> PendingResult<T> get(ApiConfig config, Class<? extends R> clazz, Map<String, String> params) { if (channel != null && !channel.isEmpty() && !params.containsKey("channel")) { params.put("channel", channel); } StringBuilder query = new StringBuilder(); for (Map.Entry<String, String> param : params.entrySet()) { query.append('&').append(param.getKey()).append("="); try { query.append(URLEncoder.encode(param.getValue(), "UTF-8")); } catch (UnsupportedEncodingException e) { // This should never happen. UTF-8 support is required for every Java implementation. throw new IllegalStateException(e); } } return getWithPath(clazz, config.fieldNamingPolicy, config.hostName, config.path, config.supportsClientId, query.toString()); } <T, R extends ApiResponse<T>> PendingResult<T> get(ApiConfig config, Class<? extends R> clazz, String... params) { if (params.length % 2 != 0) { throw new IllegalArgumentException("Params must be matching key/value pairs."); } StringBuilder query = new StringBuilder(); boolean channelSet = false; for (int i = 0; i < params.length; i++) { if (params[i].equals("channel")) { channelSet = true; } query.append('&').append(params[i]).append('='); i++; // URL-encode the parameter. try { query.append(URLEncoder.encode(params[i], "UTF-8")); } catch (UnsupportedEncodingException e) { // This should never happen. UTF-8 support is required for every Java implementation. throw new IllegalStateException(e); } } // Channel can be supplied per-request or per-context. We prioritize it from the request, so if it's not provided there, provide it here if (!channelSet && channel != null && !channel.isEmpty()) { query.append("&channel=").append(channel); } return getWithPath(clazz, config.fieldNamingPolicy, config.hostName, config.path, config.supportsClientId, query.toString()); } <T, R extends ApiResponse<T>> PendingResult<T> post(ApiConfig config, Class<? extends R> clazz, Map<String, String> params) { checkContext(config.supportsClientId); StringBuilder url = new StringBuilder(config.path); if (config.supportsClientId && clientId != null) { url.append("?client=").append(clientId); } else { url.append("?key=").append(apiKey); } if (config.supportsClientId && urlSigner != null) { String signature = urlSigner.getSignature(url.toString()); url.append("&signature=").append(signature); } String hostName = config.hostName; if (baseUrlOverride != null) { hostName = baseUrlOverride; } return requestHandler.handlePost( hostName, url.toString(), params.get("_payload"), USER_AGENT, clazz, config.fieldNamingPolicy, errorTimeout, maxRetries, exceptionsAllowedToRetry ); } private <T, R extends ApiResponse<T>> PendingResult<T> getWithPath(Class<R> clazz, FieldNamingPolicy fieldNamingPolicy, String hostName, String path, boolean canUseClientId, String encodedPath) { checkContext(canUseClientId); if (!encodedPath.startsWith("&")) { throw new IllegalArgumentException("encodedPath must start with &"); } StringBuilder url = new StringBuilder(path); if (canUseClientId && clientId != null) { url.append("?client=").append(clientId); } else { url.append("?key=").append(apiKey); } url.append(encodedPath); if (canUseClientId && urlSigner != null) { String signature = urlSigner.getSignature(url.toString()); url.append("&signature=").append(signature); } if (baseUrlOverride != null) { hostName = baseUrlOverride; } return requestHandler.handle(hostName, url.toString(), USER_AGENT, clazz, fieldNamingPolicy, errorTimeout, maxRetries, exceptionsAllowedToRetry); } private void checkContext(boolean canUseClientId) { if (urlSigner == null && apiKey == null) { throw new IllegalStateException( "Must provide either API key or Maps for Work credentials."); } else if (!canUseClientId && apiKey == null) { throw new IllegalStateException( "API does not support client ID & secret - you must provide a key"); } if (urlSigner == null && !apiKey.startsWith("AIza")) { throw new IllegalStateException("Invalid API key."); } } /** * Override the base URL of the API endpoint. Useful only for testing. * * @param baseUrl The URL to use, without a trailing slash, e.g. https://maps.googleapis.com */ GeoApiContext setBaseUrlForTesting(String baseUrl) { baseUrlOverride = baseUrl; return this; } public GeoApiContext setApiKey(String apiKey) { this.apiKey = apiKey; return this; } public GeoApiContext setEnterpriseCredentials(String clientId, String cryptographicSecret) { this.clientId = clientId; try { this.urlSigner = new UrlSigner(cryptographicSecret); } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new IllegalStateException(e); } return this; } /** * Sets the default channel for requests (can be overridden by requests). Only useful for Google * Maps for Work clients. * * @param channel The channel to use for analytics */ public GeoApiContext setChannel(String channel) { this.channel = channel; return this; } /** * Sets the default connect timeout for new connections. A value of 0 means no timeout. * * @see java.net.URLConnection#setConnectTimeout(int) */ public GeoApiContext setConnectTimeout(long timeout, TimeUnit unit) { requestHandler.setConnectTimeout(timeout, unit); return this; } /** * Sets the default read timeout for new connections. A value of 0 means no timeout. * * @see java.net.URLConnection#setReadTimeout(int) */ public GeoApiContext setReadTimeout(long timeout, TimeUnit unit) { requestHandler.setReadTimeout(timeout, unit); return this; } /** * Sets the default write timeout for new connections. A value of 0 means no timeout. */ public GeoApiContext setWriteTimeout(long timeout, TimeUnit unit) { requestHandler.setWriteTimeout(timeout, unit); return this; } /** * Sets the cumulative time limit for which retry-able errors will be retried. Defaults to 60 * seconds. Set to zero to retry requests forever. * * <p>This operates separately from the count-based {@link #setMaxRetries(Integer)}. */ public GeoApiContext setRetryTimeout(long timeout, TimeUnit unit) { this.errorTimeout = unit.toMillis(timeout); return this; } /** * Sets the maximum number of times each retry-able errors will be retried. Set this to null to not have a max number. * Set this to zero to disable retries. * * <p>This operates separately from the time-based {@link #setRetryTimeout(long, TimeUnit)}. */ public GeoApiContext setMaxRetries(Integer maxRetries) { this.maxRetries = maxRetries; return this; } /** * Disable retries completely. */ public GeoApiContext disableRetries() { setMaxRetries(0); setRetryTimeout(0, TimeUnit.MILLISECONDS); return this; } /** * Sets the maximum number of queries that will be executed during a 1 second interval. The * default is 10. A minimum interval between requests will also be enforced, set to 1/(2 * {@code * maxQps}). */ public GeoApiContext setQueryRateLimit(int maxQps) { requestHandler.setQueriesPerSecond(maxQps); return this; } /** * Sets the rate at which queries are executed. * * @param maxQps The maximum number of queries to execute per second. * @param minimumInterval The minimum amount of time, in milliseconds, to pause between requests. * Note that this pause only occurs if the amount of time between requests * has not elapsed naturally. */ public GeoApiContext setQueryRateLimit(int maxQps, int minimumInterval) { requestHandler.setQueriesPerSecond(maxQps, minimumInterval); return this; } /** * Allows specific API exceptions to be retried or not retried. */ public GeoApiContext toggleifExceptionIsAllowedToRetry(Class<? extends ApiException> exception, boolean allowedToRetry) { if (allowedToRetry) { exceptionsAllowedToRetry.add(exception); } else { exceptionsAllowedToRetry.remove(exception); } return this; } /** * Sets the proxy for new connections. * * @param proxy The proxy to be used by the underlying HTTP client. */ public GeoApiContext setProxy(Proxy proxy) { requestHandler.setProxy(proxy == null ? Proxy.NO_PROXY : proxy); return this; } }