Webb.java
package com.goebl.david;
import java.io.FilterInputStream;
import org.json.JSONArray;
import org.json.JSONObject;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLSocketFactory;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.zip.GZIPOutputStream;
/**
* Lightweight Java HTTP-Client for calling JSON REST-Services (especially for Android).
*
* @author hgoebl
*/
public class Webb {
public static final String DEFAULT_USER_AGENT = Const.DEFAULT_USER_AGENT;
public static final String APP_FORM = Const.APP_FORM;
public static final String APP_JSON = Const.APP_JSON;
public static final String APP_BINARY = Const.APP_BINARY;
public static final String TEXT_PLAIN = Const.TEXT_PLAIN;
public static final String HDR_CONTENT_TYPE = Const.HDR_CONTENT_TYPE;
public static final String HDR_CONTENT_ENCODING = Const.HDR_CONTENT_ENCODING;
public static final String HDR_ACCEPT = Const.HDR_ACCEPT;
public static final String HDR_ACCEPT_ENCODING = Const.HDR_ACCEPT_ENCODING;
public static final String HDR_USER_AGENT = Const.HDR_USER_AGENT;
public static final String HDR_AUTHORIZATION = "Authorization";
static final Map<String, Object> globalHeaders = new LinkedHashMap<String, Object>();
static String globalBaseUri;
static Integer connectTimeout = 10000; // 10 seconds
static Integer readTimeout = 3 * 60000; // 5 minutes
static int jsonIndentFactor = -1;
Boolean followRedirects;
String baseUri;
Map<String, Object> defaultHeaders;
SSLSocketFactory sslSocketFactory;
HostnameVerifier hostnameVerifier;
RetryManager retryManager;
protected Webb() {}
/**
* Create an instance which can be reused for multiple requests in the same Thread.
* @return the created instance.
*/
public static Webb create() {
return new Webb();
}
/**
* Set the value for a named header which is valid for all requests in the running JVM.
* <br>
* The value can be overwritten by calling {@link Webb#setDefaultHeader(String, Object)} and/or
* {@link com.goebl.david.Request#header(String, Object)}.
* <br>
* For the supported types for values see {@link Request#header(String, Object)}.
*
* @param name name of the header (regarding HTTP it is not case-sensitive, but here case is important).
* @param value value of the header. If <code>null</code> the header value is cleared (effectively not set).
*
* @see #setDefaultHeader(String, Object)
* @see com.goebl.david.Request#header(String, Object)
*/
public static void setGlobalHeader(String name, Object value) {
if (value != null) {
globalHeaders.put(name, value);
} else {
globalHeaders.remove(name);
}
}
/**
* Set the base URI for all requests starting in this JVM from now.
* <br>
* For all requests this value is taken as a kind of prefix for the effective URI, so you can address
* the URIs relatively. The value is only taken when {@link Webb#setBaseUri(String)} is not called or
* called with <code>null</code>.
*
* @param globalBaseUri the prefix for all URIs of new Requests.
* @see #setBaseUri(String)
*/
public static void setGlobalBaseUri(String globalBaseUri) {
Webb.globalBaseUri = globalBaseUri;
}
/**
* The number of characters to indent child properties, <code>-1</code> for "productive" code.
* <br>
* Default is production ready JSON (-1) means no indentation (single-line serialization).
* @param indentFactor the number of spaces to indent
*/
public static void setJsonIndentFactor(int indentFactor) {
Webb.jsonIndentFactor = indentFactor;
}
/**
* Set the timeout in milliseconds for connecting the server.
* <br>
* In contrast to {@link java.net.HttpURLConnection}, we use a default timeout of 10 seconds, since no
* timeout is odd.<br>
* Can be overwritten for each Request with {@link com.goebl.david.Request#connectTimeout(int)}.
* @param globalConnectTimeout the new timeout or <code><= 0</code> to use HttpURLConnection default timeout.
*/
public static void setConnectTimeout(int globalConnectTimeout) {
connectTimeout = globalConnectTimeout > 0 ? globalConnectTimeout : null;
}
/**
* Set the timeout in milliseconds for getting response from the server.
* <br>
* In contrast to {@link java.net.HttpURLConnection}, we use a default timeout of 3 minutes, since no
* timeout is odd.<br>
* Can be overwritten for each Request with {@link com.goebl.david.Request#readTimeout(int)}.
* @param globalReadTimeout the new timeout or <code><= 0</code> to use HttpURLConnection default timeout.
*/
public static void setReadTimeout(int globalReadTimeout) {
readTimeout = globalReadTimeout > 0 ? globalReadTimeout : null;
}
/**
* See <a href="http://docs.oracle.com/javase/7/docs/api/java/net/HttpURLConnection.html#setInstanceFollowRedirects(boolean)">
* </a>.
* <br>
* Use this method to set the behaviour for all requests created by this instance when receiving redirect responses.
* You can overwrite the setting for a single request by calling {@link Request#followRedirects(boolean)}.
* @param auto <code>true</code> to automatically follow redirects (HTTP status code 3xx).
* Default value comes from HttpURLConnection and should be <code>true</code>.
*/
public void setFollowRedirects(boolean auto) {
this.followRedirects = auto;
}
/**
* Set a custom {@link javax.net.ssl.SSLSocketFactory}, most likely to relax Certification checking.
* @param sslSocketFactory the factory to use (see test cases for an example).
*/
public void setSSLSocketFactory(SSLSocketFactory sslSocketFactory) {
this.sslSocketFactory = sslSocketFactory;
}
/**
* Set a custom {@link javax.net.ssl.HostnameVerifier}, most likely to relax host-name checking.
* @param hostnameVerifier the verifier (see test cases for an example).
*/
public void setHostnameVerifier(HostnameVerifier hostnameVerifier) {
this.hostnameVerifier = hostnameVerifier;
}
/**
* Set the base URI for all requests created from this instance.
* <br>
* For all requests this value is taken as a kind of prefix for the effective URI, so you can address
* the URIs relatively. The value takes precedence over the value set in {@link #setGlobalBaseUri(String)}.
*
* @param baseUri the prefix for all URIs of new Requests.
* @see #setGlobalBaseUri(String)
*/
public void setBaseUri(String baseUri) {
this.baseUri = baseUri;
}
/**
* Returns the base URI of this instance.
*
* @return base URI
*/
public String getBaseUri() {
return baseUri;
}
/**
* Set the value for a named header which is valid for all requests created by this instance.
* <br>
* The value takes precedence over {@link Webb#setGlobalHeader(String, Object)} but can be overwritten by
* {@link com.goebl.david.Request#header(String, Object)}.
* <br>
* For the supported types for values see {@link Request#header(String, Object)}.
*
* @param name name of the header (regarding HTTP it is not case-sensitive, but here case is important).
* @param value value of the header. If <code>null</code> the header value is cleared (effectively not set).
* When setting the value to null, a value from global headers can shine through.
*
* @see #setGlobalHeader(String, Object)
* @see com.goebl.david.Request#header(String, Object)
*/
public void setDefaultHeader(String name, Object value) {
if (defaultHeaders == null) {
defaultHeaders = new HashMap<String, Object>();
}
if (value == null) {
defaultHeaders.remove(name);
} else {
defaultHeaders.put(name, value);
}
}
/**
* Registers an alternative {@link com.goebl.david.RetryManager}.
* @param retryManager the new manager for deciding whether it makes sense to retry a request.
*/
public void setRetryManager(RetryManager retryManager) {
this.retryManager = retryManager;
}
/**
* Creates a <b>GET HTTP</b> request with the specified absolute or relative URI.
* @param pathOrUri the URI (will be concatenated with global URI or default URI without further checking).
* If it starts already with http:// or https:// this URI is taken and all base URIs are ignored.
* @return the created Request object (in fact it's more a builder than a real request object)
*/
public Request get(String pathOrUri) {
return new Request(this, Request.Method.GET, buildPath(pathOrUri));
}
/**
* Creates a <b>POST</b> HTTP request with the specified absolute or relative URI.
* @param pathOrUri the URI (will be concatenated with global URI or default URI without further checking)
* If it starts already with http:// or https:// this URI is taken and all base URIs are ignored.
* @return the created Request object (in fact it's more a builder than a real request object)
*/
public Request post(String pathOrUri) {
return new Request(this, Request.Method.POST, buildPath(pathOrUri));
}
/**
* Creates a <b>PUT</b> HTTP request with the specified absolute or relative URI.
* @param pathOrUri the URI (will be concatenated with global URI or default URI without further checking)
* If it starts already with http:// or https:// this URI is taken and all base URIs are ignored.
* @return the created Request object (in fact it's more a builder than a real request object)
*/
public Request put(String pathOrUri) {
return new Request(this, Request.Method.PUT, buildPath(pathOrUri));
}
/**
* Creates a <b>DELETE</b> HTTP request with the specified absolute or relative URI.
* @param pathOrUri the URI (will be concatenated with global URI or default URI without further checking)
* If it starts already with http:// or https:// this URI is taken and all base URIs are ignored.
* @return the created Request object (in fact it's more a builder than a real request object)
*/
public Request delete(String pathOrUri) {
return new Request(this, Request.Method.DELETE, buildPath(pathOrUri));
}
private String buildPath(String pathOrUri) {
if (pathOrUri == null) {
throw new IllegalArgumentException("pathOrUri must not be null");
}
if (pathOrUri.startsWith("http://") || pathOrUri.startsWith("https://")) {
return pathOrUri;
}
String myBaseUri = baseUri != null ? baseUri : globalBaseUri;
return myBaseUri == null ? pathOrUri : myBaseUri + pathOrUri;
}
<T> Response<T> execute(Request request, Class<T> clazz) {
Response<T> response = null;
if (request.retryCount == 0) {
// no retry -> just delegate to inner method
response = _execute(request, clazz);
} else {
if (retryManager == null) {
retryManager = RetryManager.DEFAULT;
}
for (int tries = 0; tries <= request.retryCount; ++tries) {
try {
response = _execute(request, clazz);
if (tries >= request.retryCount || !retryManager.isRetryUseful(response)) {
break;
}
} catch (WebbException we) {
// analyze: is exception recoverable?
if (tries >= request.retryCount || !retryManager.isRecoverable(we)) {
throw we;
}
}
if (request.waitExponential) {
retryManager.wait(tries);
}
}
}
if (response == null) {
throw new IllegalStateException(); // should never reach this line
}
if (request.ensureSuccess) {
response.ensureSuccess();
}
return response;
}
private <T> Response<T> _execute(Request request, Class<T> clazz) {
Response<T> response = new Response<T>(request);
InputStream is = null;
boolean closeStream = true;
HttpURLConnection connection = null;
try {
String uri = request.uri;
if (request.method == Request.Method.GET &&
!uri.contains("?") &&
request.params != null &&
!request.params.isEmpty()) {
uri += "?" + WebbUtils.queryString(request.params);
}
URL apiUrl = new URL(uri);
connection = (HttpURLConnection) apiUrl.openConnection();
prepareSslConnection(connection);
connection.setRequestMethod(request.method.name());
if (request.followRedirects != null) {
connection.setInstanceFollowRedirects(request.followRedirects);
}
connection.setUseCaches(request.useCaches);
setTimeouts(request, connection);
if (request.ifModifiedSince != null) {
connection.setIfModifiedSince(request.ifModifiedSince);
}
WebbUtils.addRequestProperties(connection, mergeHeaders(request.headers));
if (clazz == JSONObject.class || clazz == JSONArray.class) {
WebbUtils.ensureRequestProperty(connection, HDR_ACCEPT, APP_JSON);
}
if (request.method != Request.Method.GET && request.method != Request.Method.DELETE) {
if (request.streamPayload) {
WebbUtils.setContentTypeAndLengthForStreaming(connection, request, request.compress);
connection.setDoOutput(true);
streamBody(connection, request.payload, request.compress);
} else {
byte[] requestBody = WebbUtils.getPayloadAsBytesAndSetContentType(
connection, request, request.compress, jsonIndentFactor);
if (requestBody != null) {
connection.setDoOutput(true);
writeBody(connection, requestBody);
}
}
} else {
connection.connect();
}
response.connection = connection;
response.statusCode = connection.getResponseCode();
response.responseMessage = connection.getResponseMessage();
// get the response body (if any)
is = response.isSuccess() ? connection.getInputStream() : connection.getErrorStream();
is = WebbUtils.wrapStream(connection.getContentEncoding(), is);
if (clazz == InputStream.class) {
is = new AutoDisconnectInputStream(connection, is);
}
if (response.isSuccess()) {
WebbUtils.parseResponseBody(clazz, response, is);
} else {
WebbUtils.parseErrorResponse(clazz, response, is);
}
if (clazz == InputStream.class) {
closeStream = false;
}
return response;
} catch (WebbException e) {
throw e;
} catch (Exception e) {
throw new WebbException(e);
} finally {
if (closeStream) {
if (is != null) {
try { is.close(); } catch (Exception ignored) {}
}
if (connection != null) {
try { connection.disconnect(); } catch (Exception ignored) {}
}
}
}
}
private void setTimeouts(Request request, HttpURLConnection connection) {
if (request.connectTimeout != null || connectTimeout != null) {
connection.setConnectTimeout(
request.connectTimeout != null ? request.connectTimeout : connectTimeout);
}
if (request.readTimeout != null || readTimeout != null) {
connection.setReadTimeout(
request.readTimeout != null ? request.readTimeout : readTimeout);
}
}
private void writeBody(HttpURLConnection connection, byte[] body) throws IOException {
// Android StrictMode might complain about not closing the connection:
// "E/StrictMode﹕ A resource was acquired at attached stack trace but never released"
// It seems like some kind of bug in special devices (e.g. 4.0.4/Sony) but does not
// happen e.g. on 4.4.2/Moto G.
// Closing the stream in the try block might help sometimes (it's intermittently),
// but I don't want to deal with the IOException which can be thrown in close().
OutputStream os = null;
try {
os = connection.getOutputStream();
os.write(body);
os.flush();
} finally {
if (os != null) {
try { os.close(); } catch (Exception ignored) {}
}
}
}
private void streamBody(HttpURLConnection connection, Object body, boolean compress) throws IOException {
InputStream is;
boolean closeStream;
if (body instanceof File) {
is = new FileInputStream((File) body);
closeStream = true;
} else {
is = (InputStream) body;
closeStream = false;
}
// "E/StrictMode﹕ A resource was acquired at attached stack trace but never released"
// see comments about this problem in #writeBody()
OutputStream os = null;
try {
os = connection.getOutputStream();
if (compress) {
os = new GZIPOutputStream(os);
}
WebbUtils.copyStream(is, os);
os.flush();
} finally {
if (os != null) {
try { os.close(); } catch (Exception ignored) {}
}
if (is != null && closeStream) {
try { is.close(); } catch (Exception ignored) {}
}
}
}
private void prepareSslConnection(HttpURLConnection connection) {
if ((hostnameVerifier != null || sslSocketFactory != null) && connection instanceof HttpsURLConnection) {
HttpsURLConnection sslConnection = (HttpsURLConnection) connection;
if (hostnameVerifier != null) {
sslConnection.setHostnameVerifier(hostnameVerifier);
}
if (sslSocketFactory != null) {
sslConnection.setSSLSocketFactory(sslSocketFactory);
}
}
}
Map<String, Object> mergeHeaders(Map<String, Object> requestHeaders) {
Map<String, Object> headers = null;
if (!globalHeaders.isEmpty()) {
headers = new LinkedHashMap<String, Object>();
headers.putAll(globalHeaders);
}
if (defaultHeaders != null) {
if (headers == null) {
headers = new LinkedHashMap<String, Object>();
}
headers.putAll(defaultHeaders);
}
if (requestHeaders != null) {
if (headers == null) {
headers = requestHeaders;
} else {
headers.putAll(requestHeaders);
}
}
return headers;
}
/**
* Disconnect the underlying <code>HttpURLConnection</code> on close.
*/
private static class AutoDisconnectInputStream extends FilterInputStream {
/**
* The underlying <code>HttpURLConnection</code>.
*/
private final HttpURLConnection connection;
/**
* Creates an <code>AutoDisconnectInputStream</code>
* by assigning the argument <code>in</code>
* to the field <code>this.in</code> so as
* to remember it for later use.
* @param connection the underlying connection to disconnect on close.
* @param in the underlying input stream, or <code>null</code> if
* this instance is to be created without an underlying stream.
*/
protected AutoDisconnectInputStream(final HttpURLConnection connection, final InputStream in) {
super(in);
this.connection = connection;
}
@Override
public void close() throws IOException {
try {
super.close();
} finally {
connection.disconnect();
}
}
}
}