/*
* Copyright (c) 2009-2011 Dropbox, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package com.dropbox.client2;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Scanner;
import javax.net.ssl.SSLException;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.conn.params.ConnRoutePNames;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.params.HttpConnectionParams;
import org.apache.http.params.HttpParams;
import org.apache.http.protocol.HTTP;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import com.dropbox.client2.DropboxAPI.RequestAndResponse;
import com.dropbox.client2.exception.DropboxException;
import com.dropbox.client2.exception.DropboxIOException;
import com.dropbox.client2.exception.DropboxParseException;
import com.dropbox.client2.exception.DropboxSSLException;
import com.dropbox.client2.exception.DropboxServerException;
import com.dropbox.client2.exception.DropboxUnlinkedException;
import com.dropbox.client2.session.Session;
import com.dropbox.client2.session.Session.ProxyInfo;
/**
* This class is mostly used internally by {@link DropboxAPI} for creating and
* executing REST requests to the Dropbox API, and parsing responses. You
* probably won't have a use for it other than {@link #parseDate(String)} for
* parsing modified times returned in metadata, or (in very rare circumstances)
* writing your own API calls.
*/
public class RESTUtility {
private RESTUtility() {}
private static final DateFormat dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy kk:mm:ss ZZZZZ", Locale.US);
public enum RequestMethod {
GET, POST;
}
/**
* Creates and sends a request to the Dropbox API, parses the response as
* JSON, and returns the result.
*
* @param method GET or POST.
* @param host the hostname to use. Should be either api server,
* content server, or web server.
* @param path the URL path, starting with a '/'.
* @param apiVersion the API version to use. This should almost always be
* set to {@code DropboxAPI.VERSION}.
* @param params the URL params in an array, with the even numbered
* elements the parameter names and odd numbered elements the
* values, e.g. <code>new String[] {"path", "/Public", "locale",
* "en"}</code>.
* @param session the {@link Session} to use for this request.
*
* @return a parsed JSON object, typically a Map or a JSONArray.
*
* @throws DropboxServerException if the server responds with an error
* code. See the constants in {@link DropboxServerException} for
* the meaning of each error code.
* @throws DropboxIOException if any network-related error occurs.
* @throws DropboxUnlinkedException if the user has revoked access.
* @throws DropboxParseException if a malformed or unknown response was
* received from the server.
* @throws DropboxException for any other unknown errors. This is also a
* superclass of all other Dropbox exceptions, so you may want to
* only catch this exception which signals that some kind of error
* occurred.
*/
static public Object request(RequestMethod method, String host,
String path, int apiVersion, String[] params, Session session)
throws DropboxException {
HttpResponse resp = streamRequest(method, host, path, apiVersion,
params, session).response;
return parseAsJSON(resp);
}
/**
* Creates and sends a request to the Dropbox API, and returns a
* {@link RequestAndResponse} containing the {@link HttpUriRequest} and
* {@link HttpResponse}.
*
* @param method GET or POST.
* @param host the hostname to use. Should be either api server,
* content server, or web server.
* @param path the URL path, starting with a '/'.
* @param apiVersion the API version to use. This should almost always be
* set to {@code DropboxAPI.VERSION}.
* @param params the URL params in an array, with the even numbered
* elements the parameter names and odd numbered elements the
* values, e.g. <code>new String[] {"path", "/Public", "locale",
* "en"}</code>.
* @param session the {@link Session} to use for this request.
*
* @return a parsed JSON object, typically a Map or a JSONArray.
*
* @throws DropboxServerException if the server responds with an error
* code. See the constants in {@link DropboxServerException} for
* the meaning of each error code.
* @throws DropboxIOException if any network-related error occurs.
* @throws DropboxUnlinkedException if the user has revoked access.
* @throws DropboxException for any other unknown errors. This is also a
* superclass of all other Dropbox exceptions, so you may want to
* only catch this exception which signals that some kind of error
* occurred.
*/
static public RequestAndResponse streamRequest(RequestMethod method,
String host, String path, int apiVersion, String params[],
Session session) throws DropboxException {
HttpUriRequest req = null;
String target = null;
if (method == RequestMethod.GET) {
target = buildURL(host, apiVersion, path, params);
req = new HttpGet(target);
} else {
target = buildURL(host, apiVersion, path, null);
HttpPost post = new HttpPost(target);
if (params != null && params.length >= 2) {
if (params.length % 2 != 0) {
throw new IllegalArgumentException("Params must have an even number of elements.");
}
List<NameValuePair> nvps = new ArrayList<NameValuePair>();
for (int i = 0; i < params.length; i += 2) {
if (params[i + 1] != null) {
nvps.add(new BasicNameValuePair(params[i], params[i + 1]));
}
}
try {
post.setEntity(new UrlEncodedFormEntity(nvps, HTTP.UTF_8));
} catch (UnsupportedEncodingException e) {
throw new DropboxException(e);
}
}
req = post;
}
session.sign(req);
HttpResponse resp = execute(session, req);
return new RequestAndResponse(req, resp);
}
/**
* Reads in content from an {@link HttpResponse} and parses it as JSON.
*
* @param response the {@link HttpResponse}.
*
* @return a parsed JSON object, typically a Map or a JSONArray.
*
* @throws DropboxServerException if the server responds with an error
* code. See the constants in {@link DropboxServerException} for
* the meaning of each error code.
* @throws DropboxIOException if any network-related error occurs while
* reading in content from the {@link HttpResponse}.
* @throws DropboxUnlinkedException if the user has revoked access.
* @throws DropboxParseException if a malformed or unknown response was
* received from the server.
* @throws DropboxException for any other unknown errors. This is also a
* superclass of all other Dropbox exceptions, so you may want to
* only catch this exception which signals that some kind of error
* occurred.
*/
public static Object parseAsJSON(HttpResponse response)
throws DropboxException {
Object result = null;
BufferedReader bin = null;
try {
HttpEntity ent = response.getEntity();
if (ent != null) {
InputStreamReader in = new InputStreamReader(ent.getContent());
// Wrap this with a Buffer, so we can re-parse it if it's
// not JSON
// Has to be at least 16384, because this is defined as the buffer size in
// org.json.simple.parser.Yylex.java
// and otherwise the reset() call won't work
bin = new BufferedReader(in, 16384);
bin.mark(16384);
JSONParser parser = new JSONParser();
result = parser.parse(bin);
}
} catch (IOException e) {
throw new DropboxIOException(e);
} catch (ParseException e) {
if (DropboxServerException.isValidWithNullBody(response)) {
// We have something from Dropbox, but it's an error with no reason
throw new DropboxServerException(response);
} else {
// This is from Dropbox, and we shouldn't be getting it
throw new DropboxParseException(bin);
}
} catch (OutOfMemoryError e) {
throw new DropboxException(e);
} finally {
if (bin != null) {
try {
bin.close();
} catch (IOException e) {
}
}
}
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode != DropboxServerException._200_OK) {
if (statusCode == DropboxServerException._401_UNAUTHORIZED) {
throw new DropboxUnlinkedException();
} else {
throw new DropboxServerException(response, result);
}
}
return result;
}
/**
* Reads in content from an {@link HttpResponse} and parses it as a query
* string.
*
* @param response the {@link HttpResponse}.
*
* @return a map of parameter names to values from the query string.
*
* @throws DropboxIOException if any network-related error occurs while
* reading in content from the {@link HttpResponse}.
* @throws DropboxParseException if a malformed or unknown response was
* received from the server.
* @throws DropboxException for any other unknown errors. This is also a
* superclass of all other Dropbox exceptions, so you may want to
* only catch this exception which signals that some kind of error
* occurred.
*/
public static Map<String, String> parseAsQueryString(HttpResponse response)
throws DropboxException {
HttpEntity entity = response.getEntity();
if (entity == null) {
throw new DropboxParseException("Bad response from Dropbox.");
}
Scanner scanner;
try {
scanner = new Scanner(entity.getContent()).useDelimiter("&");
} catch (IOException e) {
throw new DropboxIOException(e);
}
Map<String, String> result = new HashMap<String, String>();
while (scanner.hasNext()) {
String nameValue = scanner.next();
String[] parts = nameValue.split("=");
if (parts.length != 2) {
throw new DropboxParseException("Bad query string from Dropbox.");
}
result.put(parts[0], parts[1]);
}
return result;
}
/**
* Executes an {@link HttpUriRequest} with the given {@link Session} and
* returns an {@link HttpResponse}.
*
* @param session the session to use.
* @param req the request to execute.
*
* @return an {@link HttpResponse}.
*
* @throws DropboxServerException if the server responds with an error
* code. See the constants in {@link DropboxServerException} for
* the meaning of each error code.
* @throws DropboxIOException if any network-related error occurs.
* @throws DropboxUnlinkedException if the user has revoked access.
* @throws DropboxException for any other unknown errors. This is also a
* superclass of all other Dropbox exceptions, so you may want to
* only catch this exception which signals that some kind of error
* occurred.
*/
public static HttpResponse execute(Session session, HttpUriRequest req)
throws DropboxException {
return execute(session, req, -1);
}
/**
* Executes an {@link HttpUriRequest} with the given {@link Session} and
* returns an {@link HttpResponse}.
*
* @param session the session to use.
* @param req the request to execute.
* @param socketTimeoutOverrideMs if >= 0, the socket timeout to set on
* this request. Does nothing if set to a negative number.
*
* @return an {@link HttpResponse}.
*
* @throws DropboxServerException if the server responds with an error
* code. See the constants in {@link DropboxServerException} for
* the meaning of each error code.
* @throws DropboxIOException if any network-related error occurs.
* @throws DropboxUnlinkedException if the user has revoked access.
* @throws DropboxException for any other unknown errors. This is also a
* superclass of all other Dropbox exceptions, so you may want to
* only catch this exception which signals that some kind of error
* occurred.
*/
public static HttpResponse execute(Session session, HttpUriRequest req,
int socketTimeoutOverrideMs) throws DropboxException {
HttpClient client = updatedHttpClient(session);
// Set request timeouts.
session.setRequestTimeout(req);
if (socketTimeoutOverrideMs >= 0) {
HttpParams reqParams = req.getParams();
HttpConnectionParams.setSoTimeout(reqParams, socketTimeoutOverrideMs);
}
try {
HttpResponse response = null;
for (int retries = 0; response == null && retries < 5; retries++) {
/*
* The try/catch is a workaround for a bug in the HttpClient
* libraries. It should be returning null instead when an
* error occurs. Fixed in HttpClient 4.1, but we're stuck with
* this for now. See:
* http://code.google.com/p/android/issues/detail?id=5255
*/
try {
response = client.execute(req);
} catch (NullPointerException e) {
}
/*
* We've potentially connected to a different network, but are
* still using the old proxy settings. Refresh proxy settings
* so that we can retry this request.
*/
if (response == null) {
updateClientProxy(client, session);
}
}
if (response == null) {
// This is from that bug, and retrying hasn't fixed it.
throw new DropboxIOException("Apache HTTPClient encountered an error. No response, try again.");
} else if (response.getStatusLine().getStatusCode() != DropboxServerException._200_OK) {
// This will throw the right thing: either a DropboxServerException or a DropboxProxyException
parseAsJSON(response);
}
return response;
} catch (SSLException e) {
throw new DropboxSSLException(e);
} catch (IOException e) {
// Quite common for network going up & down or the request being
// cancelled, so don't worry about logging this
throw new DropboxIOException(e);
} catch (OutOfMemoryError e) {
throw new DropboxException(e);
}
}
/**
* Creates a URL for a request to the Dropbox API.
*
* @param host the Dropbox host (i.e., api server, content server, or web
* server).
* @param apiVersion the API version to use. You should almost always use
* {@code DropboxAPI.VERSION} for this.
* @param target the target path, staring with a '/'.
* @param params any URL params in an array, with the even numbered
* elements the parameter names and odd numbered elements the
* values, e.g. <code>new String[] {"path", "/Public", "locale",
* "en"}</code>.
*
* @return a full URL for making a request.
*/
public static String buildURL(String host, int apiVersion,
String target, String[] params) {
if (!target.startsWith("/")) {
target = "/" + target;
}
try {
// We have to encode the whole line, then remove + and / encoding
// to get a good OAuth URL.
target = URLEncoder.encode("/" + apiVersion + target, "UTF-8");
target = target.replace("%2F", "/");
if (params != null && params.length > 0) {
target += "?" + urlencode(params);
}
// These substitutions must be made to keep OAuth happy.
target = target.replace("+", "%20").replace("*", "%2A");
} catch (UnsupportedEncodingException uce) {
return null;
}
return "https://" + host + ":443" + target;
}
/**
* Parses a date/time returned by the Dropbox API. Returns null if it
* cannot be parsed.
*
* @param date a date returned by the API.
*
* @return a {@link Date}.
*/
public static Date parseDate(String date) {
try {
return dateFormat.parse(date);
} catch (java.text.ParseException e) {
return null;
}
}
/**
* Gets the session's client and updates its proxy.
*/
private static synchronized HttpClient updatedHttpClient(Session session) {
HttpClient client = session.getHttpClient();
updateClientProxy(client, session);
return client;
}
/**
* Updates the given client's proxy from the session.
*/
private static void updateClientProxy(HttpClient client, Session session) {
ProxyInfo proxyInfo = session.getProxyInfo();
if (proxyInfo != null && proxyInfo.host != null && !proxyInfo.host.equals("")) {
HttpHost proxy;
if (proxyInfo.port < 0) {
proxy = new HttpHost(proxyInfo.host);
} else {
proxy = new HttpHost(proxyInfo.host, proxyInfo.port);
}
client.getParams().setParameter(ConnRoutePNames.DEFAULT_PROXY, proxy);
} else {
client.getParams().removeParameter(ConnRoutePNames.DEFAULT_PROXY);
}
}
/**
* URL encodes an array of parameters into a query string.
*/
private static String urlencode(String[] params) {
if (params.length % 2 != 0) {
throw new IllegalArgumentException("Params must have an even number of elements.");
}
String result = "";
try {
boolean firstTime = true;
for (int i = 0; i < params.length; i += 2) {
if (params[i + 1] != null) {
if (firstTime) {
firstTime = false;
} else {
result += "&";
}
result += URLEncoder.encode(params[i], "UTF-8") + "="
+ URLEncoder.encode(params[i + 1], "UTF-8");
}
}
result.replace("*", "%2A");
} catch (UnsupportedEncodingException e) {
return null;
}
return result;
}
}