Java tutorial
/* * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of * the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. * * Copyright (c) 2014 Digi International Inc., All Rights Reserved. */ package com.digi.wva.internal; import android.util.Log; import com.digi.wva.exc.WvaHttpException; import com.digi.wva.exc.WvaHttpException.WvaHttpBadRequest; import com.digi.wva.exc.WvaHttpException.WvaHttpForbidden; import com.digi.wva.exc.WvaHttpException.WvaHttpInternalServerError; import com.digi.wva.exc.WvaHttpException.WvaHttpNotFound; import com.digi.wva.exc.WvaHttpException.WvaHttpRequestUriTooLong; import com.digi.wva.exc.WvaHttpException.WvaHttpServiceUnavailable; import com.squareup.okhttp.Callback; import com.squareup.okhttp.Credentials; import com.squareup.okhttp.MediaType; import com.squareup.okhttp.OkHttpClient; import com.squareup.okhttp.Request; import com.squareup.okhttp.RequestBody; import com.squareup.okhttp.Response; import org.json.JSONException; import org.json.JSONObject; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.net.InetAddress; import java.net.Socket; import java.net.UnknownHostException; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Locale; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSession; import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; /** * The abstraction layer between HTTP operations by this library and the underlying HTTP services. */ @SuppressWarnings("UnusedDeclaration") public class HttpClient { /** * Wrapper around OkHttp's {@link Callback} interface, specifically targeted at * expecting and handling JSON response bodies. */ public static abstract class HttpCallback { /** * Called when an HTTP response was successfully returned from the WVA, * and the response was successfully parsed as a JSON object. * * @param response the JSON object from the HTTP response */ public abstract void onSuccess(JSONObject response); /** * Called when the HTTP request failed or had an error response. * * Called in any of these cases: * <ul> * <li>The request could not be executed due to cancellation, a connectivity problem, or a timeout.</li> * <li>An HTTP response was received, but its status code did not indicate success (e.g. 404, 500).</li> * <li>An HTTP response was received, but its body could not be parsed as a JSON object.</li> * </ul> * * @param error the {@link Throwable} causing the failure */ public abstract void onFailure(Throwable error); /** * Invoked when the HTTP request was successful, but the response body could not * be parsed as a JSON object. The default implementation logs the error and * invokes {@link #onFailure(Throwable) onFailure(Throwable)}. * * @param error the {@link JSONException} raised while parsing the response body * @param rawBody the body which could not be parsed as JSON */ public void onJsonParseError(JSONException error, String rawBody) { String errmsg = "Error parsing response as JSON: " + error.getMessage(); Log.e("HttpCallback", errmsg + "\n" + rawBody); this.onFailure(error); } } /** * Version of {@link HttpCallback} which expects the HTTP response body to be empty. * Useful as a callback for requests which do not send a body back (such as performing * the PUT requests for subscriptions, etc.). */ public static abstract class ExpectEmptyCallback extends HttpCallback { /** * Calls {@link #onBodyNotEmpty(String)}, with <b>response.toString()</b>. * * <p>This method is final so as to minimize user error.</p> */ @Override public final void onSuccess(JSONObject response) { // Response was JSON. We did not expect this. Log.w("ExpectEmptyCallback", "Got a JSON response."); onBodyNotEmpty(response.toString()); } /** * Called when the HTTP response was considered successful (status code in the 200s), * but the body was not empty. * * @param body the HTTP response body */ public abstract void onBodyNotEmpty(String body); /** * Called when the HTTP response was successfully received from the WVA, * and the response body was empty (as expected). */ public abstract void onSuccess(); /** * Invoked when the HTTP request was successful, but the response body could not * be parsed as a JSON object. * * <p>This class makes this method final to minimize user error, because this is the point * where empty response bodies are handled (so as to call {@link #onSuccess()}).</p> * * @param error the {@link JSONException} raised while parsing the response body * @param rawBody the body which could not be parsed as JSON */ @Override public final void onJsonParseError(JSONException error, String rawBody) { if (rawBody != null && rawBody.trim().length() == 0) { // JSON parsing failed because there was no content in the body. We're okay with this. onSuccess(); } else { onBodyNotEmpty(rawBody); } } } private static final String TAG = "wvalib HttpClient"; private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); /** * Provides the basic format of a WVA web service resource. * For instance: http://192.168.0.3/ws/vehicle/EngineSpeed */ private String credentials; private int httpPort = 80, httpsPort = 443; private boolean useSecureHttp; /** * If true, we will log outgoing requests and incoming responses as follows: * * Outgoing: * --> GET http://192.168.0.3/ws/vehicle/data * Incoming: * <-- 200 GET http://192.168.0.3/ws/vehicle/data */ private boolean doLogging = false; private final String hostname; private final OkHttpClient client; public class TLSSocketFactory extends SSLSocketFactory { private SSLSocketFactory internalSSLSocketFactory; public TLSSocketFactory() throws KeyManagementException, NoSuchAlgorithmException { SSLContext context = SSLContext.getInstance("TLS"); context.init(null, new X509TrustManager[] { new X509TrustManager() { @Override public X509Certificate[] getAcceptedIssuers() { return null; } @Override public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { } @Override public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { } } }, null); internalSSLSocketFactory = context.getSocketFactory(); } @Override public String[] getDefaultCipherSuites() { return internalSSLSocketFactory.getDefaultCipherSuites(); } @Override public String[] getSupportedCipherSuites() { return internalSSLSocketFactory.getSupportedCipherSuites(); } @Override public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException { return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose)); } @Override public Socket createSocket(String host, int port) throws IOException, UnknownHostException { return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)); } @Override public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException { return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort)); } @Override public Socket createSocket(InetAddress host, int port) throws IOException { return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)); } @Override public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort)); } private Socket enableTLSOnSocket(Socket socket) { if (socket != null && (socket instanceof SSLSocket)) { ((SSLSocket) socket).setEnabledProtocols(new String[] { "TLSv1.2" }); } return socket; } } /** * Returns an SSLSocketFactory which trusts any certificate. (Needed in order to connect * with the WVA when using HTTPS.) * @return an SSLSocketFactory which trusts all certificates */ private SSLSocketFactory makeSSLSocketFactory() { SSLSocketFactory factory = null; try { factory = new TLSSocketFactory(); } catch (NoSuchAlgorithmException e) { } catch (KeyManagementException e) { } return factory; } /** Constructor * * @param hostname The hostname/IP address of the WVA. */ public HttpClient(String hostname) { this.client = new OkHttpClient(); client.setSslSocketFactory(makeSSLSocketFactory()).setHostnameVerifier(new HostnameVerifier() { public boolean verify(String hostname, SSLSession session) { return true; } }); this.hostname = hostname; } /** * @return true if HTTP logging is enabled */ public boolean getLoggingEnabled() { return this.doLogging; } /** * Set whether all HTTP requests and responses should be logged to the standard Android logs * @param enabled true if requests/responses should be logged, false otherwise */ public void setLoggingEnabled(boolean enabled) { this.doLogging = enabled; } /** * Create a new {@link com.squareup.okhttp.Request.Builder Request.Builder} for accessing the * given URL, and adds the {@code Accept: application/json} header, as well as an * {@code Authorization} header if authentication is being used. * * <p>This method is protected, rather than private, due to a bug between JaCoCo and * the Android build tools which causes the instrumented bytecode to be invalid when this * method is private: * <a href="http://stackoverflow.com/questions/17603192/dalvik-transformation-using-wrong-invoke-opcode" target="_blank">see StackOverflow question.</a> * </p> * * @param url the path, relative to {@code /ws/}, on which to perform a request * @return a corresponding {@link com.squareup.okhttp.Request.Builder Request.Builder} object */ protected Request.Builder makeBuilder(String url) { Request.Builder builder = new Request.Builder().url(getAbsoluteUrl(url)).header("Accept", "application/json"); if (credentials != null) { builder.header("Authorization", credentials); } return builder; } /** * Converts a JSONObject, to be used as the body of a request, to its corresponding * {@link RequestBody} representation. * * <p>Used by {@link #post} and {@link #put}, as well as their synchronous variants.</p> * * <p>This method is protected, rather than private, due to a bug between JaCoCo and * the Android build tools which causes the instrumented bytecode to be invalid when this * method is private: * <a href="http://stackoverflow.com/questions/17603192/dalvik-transformation-using-wrong-invoke-opcode" target="_blank">see StackOverflow question.</a> * </p> * * @param obj the JSON data to be used * @return the request body */ protected RequestBody makeBody(JSONObject obj) { return obj == null ? null : RequestBody.create(JSON, obj.toString()); } /** * Wrap an HttpCallback in an OkHttp {@link Callback} instance. * Also will automatically attempt to parse the response as JSON. * * <p>This method is protected, rather than private, due to a bug between JaCoCo and * the Android build tools which causes the instrumented bytecode to be invalid when this * method is private: * <a href="http://stackoverflow.com/questions/17603192/dalvik-transformation-using-wrong-invoke-opcode" target="_blank">see StackOverflow question.</a> * </p> * @param callback the {@link com.digi.wva.internal.HttpClient.HttpCallback} to wrap * @return a corresponding {@link Callback} */ protected Callback wrapCallback(final HttpCallback callback) { return new Callback() { @Override public void onResponse(Response response) throws IOException { logResponse(response); Request request = response.request(); String responseBody = response.body().string(); if (response.isSuccessful()) { // Request succeeded. Parse JSON response. try { JSONObject parsed = new JSONObject(responseBody); callback.onSuccess(parsed); } catch (JSONException e) { callback.onJsonParseError(e, responseBody); } } else { int status = response.code(); String url = response.request().urlString(); Exception error; // Generate an appropriate exception based on the status code switch (status) { case 400: error = new WvaHttpBadRequest(url, responseBody); break; case 403: error = new WvaHttpForbidden(url, responseBody); break; case 404: error = new WvaHttpNotFound(url, responseBody); break; case 414: error = new WvaHttpRequestUriTooLong(url, responseBody); break; case 500: error = new WvaHttpInternalServerError(url, responseBody); break; case 503: error = new WvaHttpServiceUnavailable(url, responseBody); break; default: error = new WvaHttpException("HTTP " + status, url, responseBody); break; } callback.onFailure(error); } } @Override public void onFailure(Request request, IOException exception) { callback.onFailure(exception); } }; } /** * Asynchronously perform an HTTP GET request on the given path. * * @param url the path, relative to {@code /ws/}, on which to perform a GET request * @param callback a callback for when the request completes or is in error */ public void get(String url, HttpCallback callback) { Request request = makeBuilder(url).get().build(); logRequest(request); client.newCall(request).enqueue(wrapCallback(callback)); } /** * Asynchronously perform an HTTP POST request on the given path, * with the given JSON data. * * @param url the path, relative to {@code /ws/}, on which to perform a POST request * @param obj the JSON object to POST to the device * @param callback a callback for when the request completes or is in error */ public void post(String url, JSONObject obj, HttpCallback callback) { Request request = this.makeBuilder(url).method("POST", this.makeBody(obj)).build(); logRequest(request); client.newCall(request).enqueue(wrapCallback(callback)); } /** * Asynchronously perform an HTTP PUT request on the given path, * with the given JSON data. * * @param url the path, relative to {@code /ws/}, on which to perform a PUT request * @param obj the JSON object to PUT to the device * @param callback a callback for when the request completes or is in error */ public void put(String url, JSONObject obj, HttpCallback callback) { Request request = this.makeBuilder(url).put(this.makeBody(obj)).build(); logRequest(request); client.newCall(request).enqueue(wrapCallback(callback)); } /** * Asynchronously perform an HTTP DELETE request on the given path. * * @param url the path, relative to {@code /ws/}, on which to perform a DELETE request * @param callback a callback for when the request completes or is in error */ public void delete(String url, HttpCallback callback) { Request request = this.makeBuilder(url).delete().build(); logRequest(request); client.newCall(request).enqueue(wrapCallback(callback)); } /** * Log information of OkHttp Request objects * * <p>This method is protected, rather than private, due to a bug between JaCoCo and * the Android build tools which causes the instrumented bytecode to be invalid when this * method is private: * <a href="http://stackoverflow.com/questions/17603192/dalvik-transformation-using-wrong-invoke-opcode" target="_blank">see StackOverflow question.</a> * </p> */ protected void logRequest(Request request) { if (!doLogging) { // Logging is disabled - do nothing. return; } Log.i(TAG, "\u2192 " + request.method() + " " + request.urlString()); } /** * Log information of OkHttp Response objects * * <p>This method is protected, rather than private, due to a bug between JaCoCo and * the Android build tools which causes the instrumented bytecode to be invalid when this * method is private: * <a href="http://stackoverflow.com/questions/17603192/dalvik-transformation-using-wrong-invoke-opcode" target="_blank">see StackOverflow question.</a> * </p> * @param response the HTTP response object to log */ protected void logResponse(Response response) { if (!doLogging) { // Logging is disabled - do nothing. return; } Request request = response.request(); StringBuilder log = new StringBuilder(); log.append( // e.g. <-- 200 GET /ws/hw/leds/foo String.format("\u2190 %d %s %s", response.code(), request.method(), request.urlString())); // Add on lines tracking any redirects that occurred. Response prior = response.priorResponse(); if (prior != null) { // Call out that there were prior responses. log.append(" (prior responses below)"); // Add a line to the log message for each prior response. // (For most if not all responses, there will likely be just one.) do { log.append(String.format("\n... prior response: %d %s %s", prior.code(), prior.request().method(), prior.request().urlString())); // If this is a redirect, log the URL we're being redirected to. if (prior.isRedirect()) { log.append(", redirecting to "); log.append(prior.header("Location", "[no Location header found?!]")); } prior = prior.priorResponse(); } while (prior != null); } Log.i(TAG, log.toString()); } /** * Given a relative path (such as {@code "config"}), return a {@link URL} object representing * the corresponding full path in the WVA's web services (such as * {@code "http://192.168.100.1/ws/config"}). * * <p>This method is used internally by {@link HttpClient} when constructing its HTTP requests.</p> * * @param relativePath the web services path to use * @return a {link URL} representing the full path to <b>relativePath</b> */ public URL getAbsoluteUrl(String relativePath) { String scheme = (this.useSecureHttp ? "https" : "http"); int port = (this.useSecureHttp ? httpsPort : httpPort); try { return new URL(scheme, this.hostname, port, "/ws/" + relativePath); } catch (MalformedURLException e) { String formatted = String.format(Locale.US, "%s://%s:%d/ws/%s", scheme, this.hostname, port, relativePath); Log.wtf(TAG, "Malformed URL: " + formatted); throw new AssertionError("Malformed URL: " + formatted); } } /** * Sets the basic authentication parameters to be added to every HTTP request made by this client. * * @param username the username for authentication * @param password the password for authentication */ public void useBasicAuth(String username, String password) { credentials = Credentials.basic(username, password); } /** * Clears any previously-set basic authentication for HTTP requests. */ public void clearBasicAuth() { credentials = null; } /** * Sets whether this HTTP client should communicate over HTTP or HTTPS. * @param secure true to use HTTPS, false to use HTTP */ public void useSecureHttp(boolean secure) { this.useSecureHttp = secure; } /** * Sets the port to be used when making HTTP requests. * * @param port the port to use for HTTP */ public void setHttpPort(int port) { this.httpPort = port; } /** * Sets the port to be used when making HTTPS requests. * * @param port the port to use for HTTPS */ public void setHttpsPort(int port) { this.httpsPort = port; } /** * Gets the underlying HTTP client used by this HttpClient instance. * * @return the underlying HTTP client */ OkHttpClient getUnderlyingClient() { return this.client; } }