/* * (c) Copyright Reserved EVRYTHNG Limited 2016. All rights reserved. * Use of this material is subject to license. * Copying and unauthorised use of this material strictly prohibited. */ package com.evrythng.java.wrapper.core.api; import com.evrythng.java.wrapper.core.http.HttpMethodBuilder; import com.evrythng.java.wrapper.core.http.HttpMethodBuilder.Method; import com.evrythng.java.wrapper.core.http.HttpMethodBuilder.MethodBuilder; import com.evrythng.java.wrapper.core.http.Status; import com.evrythng.java.wrapper.exception.EvrythngClientException; import com.evrythng.java.wrapper.exception.EvrythngException; import com.evrythng.java.wrapper.util.LogUtils; import com.evrythng.java.wrapper.util.URIBuilder; import com.evrythng.thng.commons.config.ApiConfiguration; import com.fasterxml.jackson.core.type.TypeReference; import org.apache.commons.collections.map.MultiValueMap; import org.apache.http.Header; import org.apache.http.HttpResponse; import org.apache.http.client.HttpClient; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.conn.ClientConnectionManager; import org.apache.http.conn.ConnectTimeoutException; import org.apache.http.conn.scheme.Scheme; import org.apache.http.conn.scheme.SchemeRegistry; import org.apache.http.conn.ssl.SSLSocketFactory; import org.apache.http.impl.client.DefaultHttpClient; import org.apache.http.params.BasicHttpParams; import org.apache.http.params.HttpConnectionParams; import org.apache.http.params.HttpParams; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.InputStream; import java.net.URI; import java.security.KeyStore; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Random; import java.util.concurrent.TimeUnit; /** * Generic definition for API commands. */ public class ApiCommand<T> { private static final Logger logger = LoggerFactory.getLogger(ApiCommand.class); private static final Random RANDOM = new Random(); private static final int DEFAULT_CONNECTION_TIMEOUT = 5000; private static final int DEFAULT_SOCKET_TIMEOUT = 30000; /** * The number of times a failed HTTP connection attempt should be retried. */ private static final int CONNECTION_RETRY_ATTEMPTS = 5; /** * The number of milliseconds of variability to use when choosing the wait interval between 2 connection retries. */ private static final int CONNECTION_RETRY_MILLISECONDS_RANDOM = 1000; /** * Retry to reconnect when connect timeout occurs. */ private static final boolean RETRY_ON_CONNECT_TIMEOUT = true; /** * Do not retry to reconnect when connect timeout occurs. */ private static final boolean DO_NOT_RETRY_ON_CONNECT_TIMEOUT = false; private MultiValueMap queryParams = new MultiValueMap(); private Map<String, String> headers = new LinkedHashMap<>(); private HttpParams httpParams = new BasicHttpParams(); { HttpConnectionParams.setConnectionTimeout(httpParams, DEFAULT_CONNECTION_TIMEOUT); HttpConnectionParams.setSoTimeout(httpParams, DEFAULT_SOCKET_TIMEOUT); } private MethodBuilder<?> methodBuilder; private URI uri; private Status responseStatus; private TypeReference<T> responseType; /** * Creates a new instance of {@link ApiCommand}. * * @param methodBuilder the {@link MethodBuilder} used for creating the * request * @param uri the {@link URI} holding the absolute URL * @param responseStatus the expected {@link HttpResponse} status * @param responseType the native type to which the {@link HttpResponse} will be * mapped to */ public ApiCommand(final MethodBuilder<?> methodBuilder, final URI uri, final Status responseStatus, final TypeReference<T> responseType) { this.methodBuilder = methodBuilder; this.uri = uri; this.responseStatus = responseStatus; this.responseType = responseType; } /** * Gets the expected response status. * * @return {@link Status} instance */ public Status getExpectedResponseStatus() { return responseStatus; } /** * Gets the response type. * * @return {@link TypeReference} instance */ public TypeReference<T> getResponseType() { return responseType; } /** * Gets the HTTP method. * * @return {@link Method} type */ public Method getMethod() { return methodBuilder.getMethod(); } /** * Executes the current command and maps the {@link HttpResponse} entity to * {@code T} specified by {@link ApiCommand#responseType}. * * @return the {@link HttpResponse} entity mapped to {@code T} */ public T execute() throws EvrythngException { return execute(responseType, DO_NOT_RETRY_ON_CONNECT_TIMEOUT); } /** * <p>Executes the current command and maps the {@link HttpResponse} entity to * {@code T} specified by {@link ApiCommand#responseType}.</p> * <p> * <p>If the <code>retryOnConnectTimeout</code> parameter is true the connection will be attempted up to * {@link #CONNECTION_RETRY_ATTEMPTS} times.</p> * * @param retryOnConnectTimeout if true the connection will be attempted up to * {@link #CONNECTION_RETRY_ATTEMPTS} times when a connect timeout is encountered * * @return the {@link HttpResponse} entity mapped to {@code T} * * @throws EvrythngException in case an exception is encountered during the request */ public T execute(final boolean retryOnConnectTimeout) throws EvrythngException { return execute(responseType, retryOnConnectTimeout); } /** * Executes the current command and returns the {@link HttpResponse} entity * content as {@link String}. * * @return the {@link HttpResponse} entity content as {@link String} */ public String content() throws EvrythngException { return execute(new TypeReference<String>() {}, DO_NOT_RETRY_ON_CONNECT_TIMEOUT); } /** * Executes the current command and returns the native {@link HttpResponse}. * * @return the {@link HttpResponse} implied by the request */ public HttpResponse request() throws EvrythngException { return execute(new TypeReference<HttpResponse>() {}, DO_NOT_RETRY_ON_CONNECT_TIMEOUT); } /** * Executes the current command and returns the {@link HttpResponse} entity * body as {@link InputStream}. * * @return the {@link HttpResponse} entity as {@link InputStream} */ public InputStream stream() throws EvrythngException { return execute(new TypeReference<InputStream>() {}, DO_NOT_RETRY_ON_CONNECT_TIMEOUT); } /** * Execute the current command and returns both {@link HttpResponse} and * the entity typed. Bundled in a {@link TypedResponseWithEntity} object * * @return {@link HttpResponse} bundled with entity */ public TypedResponseWithEntity<T> bundle() throws EvrythngException { HttpClient client = new DefaultHttpClient(httpParams); client = wrapClient(client); try { HttpResponse response = performRequest(client, methodBuilder, responseStatus); T entity = Utils.convert(response, responseType); return new TypedResponseWithEntity<>(response, entity); } finally { shutdown(client); } } /** * Executes the current command using the HTTP {@code HEAD} method and * returns the value of the first {@link HttpResponse} {@link Header} * specified by {@code headerName}. This * method is usefull for obtaining * metainformation about the {@link HttpResponse} implied by the request * without transferring the entity-body. * <p> * FIXME: HEAD not supported for now, using GET instead * * @param headerName the {@link HttpResponse} header to be retrieved * @return the value of the first retrieved {@link HttpResponse} header or * null if no such header could be found. * @see HttpResponse#getFirstHeader(String) */ public Header head(final String headerName) throws EvrythngException { HttpResponse response = execute(HttpMethodBuilder.httpGet(), new TypeReference<HttpResponse>() {}, DO_NOT_RETRY_ON_CONNECT_TIMEOUT); logger.debug("Retrieving first header: [name={}]", headerName); return response.getFirstHeader(headerName); } /** * Sets (adds or overwrittes) the specified request header. * * @param name the request header name * @param value the request header value */ public void setHeader(final String name, final String value) { logger.debug("Setting header: [name={}, value={}]", name, ApiConfiguration.HTTP_HEADER_AUTHORIZATION.equals(name) ? LogUtils.maskApiKey(value) : value); headers.put(name, value); } /** * Removes the specified request header. * * @param name the name of the request header to be removed */ public void removeHeader(final String name) { logger.debug("Removing header: [name={}]", name); headers.remove(name); } /** * Sets (adds or overwrittes) the specified query parameter. * * @param name the query parameter name * @param value the query parameter value */ public void setQueryParam(final String name, final String value) { // Ensure unicity of parameter: queryParams.remove(name); logger.debug("Setting query parameter: [name={}, value={}]", name, value); queryParams.put(name, value); } public void setQueryParam(final QueryParamValue queryParam) { // Ensure uniqueness of parameter: queryParams.remove(queryParam.getKey()); logger.debug("Setting query parameter: [name={}, value={}]", queryParam.getKey(), queryParam.getValue()); queryParams.put(queryParam.getKey(), queryParam.getValue()); } /** * Sets (adds or overwrittes) the multi-value of specified query parameter. * * @param name the query parameter name * @param value the query parameter values list */ public void setQueryParam(final String name, final List<String> value) { logger.debug("Setting query parameter: [name={}, value={}]", name, value); queryParams.putAll(name, value); } /** * Removes the specified query parameter. * * @param name the name of the query parameter to be removed */ public void removeQueryParam(final String name) { logger.debug("Removing query parameter: [name={}]", name); queryParams.remove(name); } /** * Sets HTTP-specific params, { * * @param params {@link HttpParams} instance * @see HttpClient */ public void setHttpParams(final HttpParams params) { logger.debug("Setting HttpParams: [{}]", params); this.httpParams = params; } private <K> K execute(final TypeReference<K> type, final boolean retryOnConnectTimeout) throws EvrythngException { // Delegate: return execute(methodBuilder, type, retryOnConnectTimeout); } private <K> K execute(final MethodBuilder<?> method, final TypeReference<K> type, final boolean retryOnConnectTimeout) throws EvrythngException { // Delegate: return execute(method, responseStatus, type, retryOnConnectTimeout); } private <K> K execute(final MethodBuilder<?> method, final Status expectedStatus, final TypeReference<K> type, final boolean retryOnConnectTimeout) throws EvrythngException { HttpClient client = new DefaultHttpClient(httpParams); client = wrapClient(client); try { HttpResponse response; if (retryOnConnectTimeout) { response = performRequestWithRetry(client, method, expectedStatus); } else { response = performRequest(client, method, expectedStatus); } return Utils.convert(response, type); } finally { shutdown(client); } } private HttpResponse performRequest(final HttpClient client, final MethodBuilder<?> method, final Status expectedStatus) throws EvrythngException { HttpResponse response; HttpUriRequest request = buildRequest(method); try { logger.debug(">> Executing request: [method={}, url={}]", request.getMethod(), request.getURI().toString()); response = client.execute(request); logger.debug("<< Response received: [statusLine={}]", response.getStatusLine().toString()); } catch (Exception e) { // Convert to custom exception: throw new EvrythngClientException(String.format("Unable to execute request: [uri=%s, cause=%s]", request.getURI(), e.getMessage()), e); } // Assert response status: Utils.assertStatus(response, expectedStatus); return response; } /** * Performs a HTTP request and retries it multiple times in case of connection timeout. * * @param client the HTTP client to use * @param method the HTTP method builder to use * @param expectedStatus the HTTP status expected for the result * * @return the response of the HTTP request * * @throws EvrythngException in case an exception is encountered during the request */ private HttpResponse performRequestWithRetry(final HttpClient client, final MethodBuilder<?> method, final Status expectedStatus) throws EvrythngException { HttpResponse response = null; HttpUriRequest request = buildRequest(method); // the number of HTTP request attempts have been performed int requestAttemptsPerformed = 0; // this variable will become true when the connection to the remote HTTP server will be successful boolean connectionSucceeded = false; while (!connectionSucceeded && requestAttemptsPerformed < CONNECTION_RETRY_ATTEMPTS) { final long requestAttemptStartMs = System.currentTimeMillis(); try { requestAttemptsPerformed++; logger.debug(">> Executing request: [method={}, url={}]", request.getMethod(), request.getURI().toString()); response = client.execute(request); connectionSucceeded = true; logger.debug("<< Response received: [statusLine={}]", response.getStatusLine().toString()); } catch (ConnectTimeoutException connectTimeoutException) { // time the duration for logging purposes final long requestAttemptDurationMs = System.currentTimeMillis() - requestAttemptStartMs; // Log the exception logger.warn("CONNECT_TIMEOUT_EXCEPTION: Attempt #: [{}/{}], Duration: {}ms, URI:[{}]", new Object[] {requestAttemptsPerformed, CONNECTION_RETRY_ATTEMPTS, requestAttemptDurationMs, request.getURI(), connectTimeoutException}); if (requestAttemptsPerformed >= CONNECTION_RETRY_ATTEMPTS) { // If the number of request attempts has exceeded the maximum allowed throw an EvrythngClientException throw new EvrythngClientException(String.format("Unable to execute request: [uri=%s, cause=%s]", request.getURI(), connectTimeoutException.getMessage()), connectTimeoutException); } else { // The number of requests has not exceeded yet, wait for a bit and perform the request again sleepBetweenHttpRequests(requestAttemptsPerformed, request); } } catch (Exception e) { // Convert to custom exception: throw new EvrythngClientException(String.format("Unable to execute request: [uri=%s, cause=%s]", request.getURI(), e.getMessage()), e); } } // HTTP request successful, Assert response status: Utils.assertStatus(response, expectedStatus); return response; } /** * <p>Performs a {@link Thread#sleep(long)} for a certain amount of time depending on the number of connection attempts have been made until this point.</p> * <p> * <p>The formula to calculate the wait time is the following:<br /> * waitTimeMilliseconds = 2 * requestAttempts * 1000 + randomBetween(-500, 500)</p> * * @param requestAttemptsPerformed the number of connection attempts have been made until this point. * @param request the request to be performed. * * @throws EvrythngClientException in case a {@link InterruptedException} is encountered during the {@link Thread#sleep(long)} */ private void sleepBetweenHttpRequests(final int requestAttemptsPerformed, final HttpUriRequest request) { try { final int nextInt = RANDOM.nextInt(CONNECTION_RETRY_MILLISECONDS_RANDOM); final int waitTimeRandom = nextInt - (CONNECTION_RETRY_MILLISECONDS_RANDOM / 2); final long waitTime = TimeUnit.SECONDS.toMillis(2 * requestAttemptsPerformed) + waitTimeRandom; logger.info("The thread will sleep for {}ms before attempting a new request to: [{}]", waitTime, request.getURI()); Thread.sleep(waitTime); } catch (InterruptedException e) { throw new EvrythngClientException(String.format("InterruptedException while waiting to perform request: [uri=%s]", request.getURI()), e); } } private static HttpClient wrapClient(final HttpClient base) { try { KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); trustStore.load(null, null); SSLSocketFactory ssf = new WrapperSSLSocketFactory(trustStore); ssf.setHostnameVerifier(SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER); ClientConnectionManager ccm = base.getConnectionManager(); SchemeRegistry sr = ccm.getSchemeRegistry(); sr.register(new Scheme("https", ssf, 443)); return new DefaultHttpClient(ccm, base.getParams()); } catch (Exception ex) { return null; } } /** * Builds and prepares the {@link HttpUriRequest}. * * @param method the {@link MethodBuilder} used to build the request * @return the prepared {@link HttpUriRequest} for execution */ private HttpUriRequest buildRequest(final MethodBuilder<?> method) throws EvrythngClientException { // Build request method: HttpUriRequest request = method.build(uri()); // Define client headers: for (Entry<String, String> header : headers.entrySet()) { request.setHeader(header.getKey(), header.getValue()); } return request; } /** * Builds the final {@link URI} using {@link ApiCommand#uri} as base URL and * all defined {@link ApiCommand#queryParams} as query parameters. * * @return the absolute URI */ public final URI uri() throws EvrythngClientException { return URIBuilder.fromUri(uri.toString()).queryParams(queryParams).build(); } /** * Shuts down the connection manager to ensure immediate deallocation of all * system resources. * * @param client the {@link HttpClient} to shut down */ protected void shutdown(final HttpClient client) { client.getConnectionManager().shutdown(); } }