Created
October 26, 2015 23:04
-
-
Save fabiankessler/a6cb51e247a8c450d4aa to your computer and use it in GitHub Desktop.
RestApiClient thread safe
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Thread safe. | |
*/ | |
public class RestApiClient { | |
/** | |
* Creation of these Client instances is expensive, and according to its Javadoc, they | |
* should be shared. | |
*/ | |
private static ConcurrentMap<String, Client> hostMap = new ConcurrentHashMap<>(); | |
public static class Builder { | |
private String basePath; | |
private Map<String, String> defaultHeaderMap = new HashMap<>(); | |
private boolean debug = false; | |
private JSON json = new JSON(); | |
private DateFormat dateFormat; | |
public Builder basePath(String basePath) { | |
this.basePath = basePath; | |
return this; | |
} | |
public Builder defaultHeader(String key, String value) { | |
defaultHeaderMap.put(key, value); | |
return this; | |
} | |
/** | |
* Set the User-Agent header's value (by adding to the default header map). | |
*/ | |
public Builder userAgent(String userAgent) { | |
defaultHeader("User-Agent", userAgent); | |
return this; | |
} | |
public Builder debug(boolean debug) { | |
this.debug = debug; | |
return this; | |
} | |
public Builder json(JSON json) { | |
this.json = json; | |
return this; | |
} | |
public Builder dateFormat(DateFormat dateFormat) { | |
this.dateFormat = dateFormat; | |
return this; | |
} | |
public RestApiClient build() { | |
if (dateFormat==null) { | |
// Use ISO 8601 format for date and datetime. | |
// See https://en.wikipedia.org/wiki/ISO_8601 | |
dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ"); | |
// Use UTC as the default time zone. | |
dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); | |
} | |
return new RestApiClient( | |
basePath, | |
defaultHeaderMap, | |
debug, | |
json, | |
dateFormat | |
); | |
} | |
} | |
private final String basePath; | |
private final Map<String, String> defaultHeaderMap; | |
private final boolean debug; | |
private final JSON json; | |
private final DateFormat dateFormat; | |
private RestApiClient(String basePath, Map<String, String> defaultHeaderMap, boolean debug, JSON json, DateFormat dateFormat) { | |
this.basePath = basePath; | |
this.defaultHeaderMap = defaultHeaderMap; | |
this.debug = debug; | |
this.json = json; | |
this.dateFormat = dateFormat; | |
} | |
public String getBasePath() { | |
return basePath; | |
} | |
/** | |
* Check that whether debugging is enabled for this API client. | |
*/ | |
public boolean isDebug() { | |
return debug; | |
} | |
/** | |
* Get the date format used to parse/format date parameters. | |
*/ | |
public DateFormat getDateFormat() { | |
return dateFormat; | |
} | |
/** | |
* Parse the given string into Date object. | |
*/ | |
public Date parseDate(String str) { | |
try { | |
return dateFormat.parse(str); | |
} catch (ParseException e) { | |
throw new RuntimeException(e); | |
} | |
} | |
/** | |
* Format the given Date object into string. | |
*/ | |
public String formatDate(Date date) { | |
return dateFormat.format(date); | |
} | |
/** | |
* Format the given parameter object into string. | |
*/ | |
public String parameterToString(Object param) { | |
if (param == null) { | |
return ""; | |
} else if (param instanceof Date) { | |
return formatDate((Date) param); | |
} else if (param instanceof Collection) { | |
StringBuilder b = new StringBuilder(); | |
for (Object o : (Collection) param) { | |
if (b.length() > 0) { | |
b.append(","); | |
} | |
b.append(String.valueOf(o)); | |
} | |
return b.toString(); | |
} else { | |
return String.valueOf(param); | |
} | |
} | |
/* | |
Format to {@code Pair} objects. | |
*/ | |
public List<Pair> parameterToPairs(String collectionFormat, String name, Object value) { | |
List<Pair> params = new ArrayList<>(); | |
// preconditions | |
if (name == null || name.isEmpty() || value == null) return params; | |
Collection valueCollection; | |
if (value instanceof Collection) { | |
valueCollection = (Collection) value; | |
} else { | |
params.add(new Pair(name, parameterToString(value))); | |
return params; | |
} | |
if (valueCollection.isEmpty()) { | |
return params; | |
} | |
// get the collection format | |
collectionFormat = (collectionFormat == null || collectionFormat.isEmpty() ? "csv" : collectionFormat); // default: csv | |
// create the params based on the collection format | |
if (collectionFormat.equals("multi")) { | |
for (Object item : valueCollection) { | |
params.add(new Pair(name, parameterToString(item))); | |
} | |
return params; | |
} | |
String delimiter = ","; | |
if (collectionFormat.equals("csv")) { | |
delimiter = ","; | |
} else if (collectionFormat.equals("ssv")) { | |
delimiter = " "; | |
} else if (collectionFormat.equals("tsv")) { | |
delimiter = "\t"; | |
} else if (collectionFormat.equals("pipes")) { | |
delimiter = "|"; | |
} | |
StringBuilder sb = new StringBuilder(); | |
for (Object item : valueCollection) { | |
sb.append(delimiter); | |
sb.append(parameterToString(item)); | |
} | |
params.add(new Pair(name, sb.substring(1))); | |
return params; | |
} | |
/** | |
* Select the Accept header's value from the given accepts array: | |
* if JSON exists in the given array, use it; | |
* otherwise use all of them (joining into a string) | |
* | |
* @param accepts The accepts array to select from | |
* @return The Accept header to use. If the given array is empty, | |
* null will be returned (not to set the Accept header explicitly). | |
*/ | |
public String selectHeaderAccept(String[] accepts) { | |
if (accepts.length == 0) return null; | |
if (StringUtil.containsIgnoreCase(accepts, "application/json")) return "application/json"; | |
return StringUtil.join(accepts, ","); | |
} | |
/** | |
* Select the Content-Type header's value from the given array: | |
* if JSON exists in the given array, use it; | |
* otherwise use the first one of the array. | |
* | |
* @param contentTypes The Content-Type array to select from | |
* @return The Content-Type header to use. If the given array is empty, | |
* JSON will be used. | |
*/ | |
public String selectHeaderContentType(String[] contentTypes) { | |
if (contentTypes.length == 0) return "application/json"; | |
if (StringUtil.containsIgnoreCase(contentTypes, "application/json")) return "application/json"; | |
return contentTypes[0]; | |
} | |
/** | |
* Escape the given string to be used as URL query value. | |
*/ | |
public String escapeString(String str) { | |
try { | |
return URLEncoder.encode(str, "utf8").replaceAll("\\+", "%20"); | |
} catch (UnsupportedEncodingException e) { | |
return str; | |
} | |
} | |
/** | |
* Serialize the given Java object into string according the given | |
* Content-Type (only JSON is supported for now). | |
*/ | |
public String serialize(Object obj, String contentType) throws RestApiException { | |
if (contentType.startsWith("application/json")) { | |
return json.serialize(obj); | |
} else { | |
throw new RestApiException(400, "can not serialize object into Content-Type: " + contentType); | |
} | |
} | |
/** | |
* Deserialize response body to Java object according to the Content-Type. | |
*/ | |
public <T> T deserialize(ClientResponse response, TypeRef returnType) throws RestApiException { | |
String contentType = null; | |
List<String> contentTypes = response.getHeaders().get("Content-Type"); | |
if (contentTypes != null && !contentTypes.isEmpty()) | |
contentType = contentTypes.get(0); | |
if (contentType == null) | |
throw new RestApiException(500, "missing Content-Type in response"); | |
String body; | |
if (response.hasEntity()) | |
body = (String) response.getEntity(String.class); | |
else | |
body = ""; | |
if (contentType.startsWith("application/json")) { | |
return json.deserialize(body, returnType); | |
} else if (returnType.getType().equals(String.class)) { | |
// Expecting string, return the raw response body. | |
return (T) body; | |
} else { | |
throw new RestApiException( | |
500, | |
"Content type \"" + contentType + "\" is not supported for type: " | |
+ returnType.getType() | |
); | |
} | |
} | |
private ClientResponse getAPIResponse(String path, String method, List<Pair> queryParams, Object body, byte[] binaryBody, Map<String, String> headerParams, Map<String, Object> formParams, String accept, String contentType, String[] authNames) throws RestApiException { | |
if (body != null && binaryBody != null) { | |
throw new RestApiException(500, "Either body or binaryBody must be null"); | |
} | |
Client client = getClient(); | |
String querystring = makeQueryString(queryParams); | |
WebResource.Builder builder; | |
if (accept == null) | |
builder = client.resource(basePath + path + querystring).getRequestBuilder(); | |
else | |
builder = client.resource(basePath + path + querystring).accept(accept); | |
for (String key : headerParams.keySet()) { | |
builder = builder.header(key, headerParams.get(key)); | |
} | |
for (String key : defaultHeaderMap.keySet()) { | |
if (!headerParams.containsKey(key)) { | |
builder = builder.header(key, defaultHeaderMap.get(key)); | |
} | |
} | |
String encodedFormParams = null; | |
if (contentType.startsWith("multipart/form-data")) { | |
FormDataMultiPart mp = new FormDataMultiPart(); | |
for (Entry<String, Object> param : formParams.entrySet()) { | |
if (param.getValue() instanceof File) { | |
File file = (File) param.getValue(); | |
mp.field(param.getKey(), file.getName()); | |
mp.bodyPart(new FileDataBodyPart(param.getKey(), file, MediaType.MULTIPART_FORM_DATA_TYPE)); | |
} else { | |
mp.field(param.getKey(), parameterToString(param.getValue()), MediaType.MULTIPART_FORM_DATA_TYPE); | |
} | |
} | |
body = mp; | |
} else if (contentType.startsWith("application/x-www-form-urlencoded")) { | |
encodedFormParams = this.getXWWWFormUrlencodedParams(formParams); | |
} | |
ClientResponse response; | |
if ("GET".equals(method)) { | |
response = builder.get(ClientResponse.class); | |
} else if ("POST".equals(method)) { | |
if (encodedFormParams != null) { | |
response = builder.type(contentType).post(ClientResponse.class, encodedFormParams); | |
} else if (body == null) { | |
if (binaryBody == null) | |
response = builder.post(ClientResponse.class, null); | |
else | |
response = builder.type(contentType).post(ClientResponse.class, binaryBody); | |
} else if (body instanceof FormDataMultiPart) { | |
response = builder.type(contentType).post(ClientResponse.class, body); | |
} else { | |
response = builder.type(contentType).post(ClientResponse.class, serialize(body, contentType)); | |
} | |
} else if ("PUT".equals(method)) { | |
if (encodedFormParams != null) { | |
response = builder.type(contentType).put(ClientResponse.class, encodedFormParams); | |
} else if (body == null) { | |
if (binaryBody == null) | |
response = builder.put(ClientResponse.class, null); | |
else | |
response = builder.type(contentType).put(ClientResponse.class, binaryBody); | |
} else { | |
response = builder.type(contentType).put(ClientResponse.class, serialize(body, contentType)); | |
} | |
} else if ("DELETE".equals(method)) { | |
if (encodedFormParams != null) { | |
response = builder.type(contentType).delete(ClientResponse.class, encodedFormParams); | |
} else if (body == null) { | |
if (binaryBody == null) | |
response = builder.delete(ClientResponse.class); | |
else | |
response = builder.type(contentType).delete(ClientResponse.class, binaryBody); | |
} else { | |
response = builder.type(contentType).delete(ClientResponse.class, serialize(body, contentType)); | |
} | |
} else { | |
throw new RestApiException(500, "unknown method type " + method); | |
} | |
return response; | |
} | |
private String makeQueryString(List<Pair> queryParams) { | |
if (queryParams == null || queryParams.isEmpty()) { | |
return ""; | |
} | |
StringBuilder b = new StringBuilder(); | |
b.append("?"); | |
for (Pair queryParam : queryParams) { | |
if (!queryParam.getName().isEmpty()) { | |
b.append(escapeString(queryParam.getName())); | |
b.append("="); | |
b.append(escapeString(queryParam.getValue())); | |
b.append("&"); | |
} | |
} | |
return b.substring(0, b.length() - 1); // -1 to remove the last ampersand. | |
} | |
/** | |
* Invoke API by sending HTTP request with the given options. | |
* | |
* @param path The sub-path of the HTTP URL | |
* @param method The request method, one of "GET", "POST", "PUT", and "DELETE" | |
* @param queryParams The query parameters | |
* @param body The request body object - if it is not binary, otherwise null | |
* @param binaryBody The request body object - if it is binary, otherwise null | |
* @param headerParams The header parameters | |
* @param formParams The form parameters | |
* @param accept The request's Accept header | |
* @param contentType The request's Content-Type header | |
* @param authNames The authentications to apply | |
* @return The response body in type of string | |
*/ | |
public <T> RestApiClientResponse<T> invokeAPI(String path, String method, List<Pair> queryParams, Object body, byte[] binaryBody, Map<String, String> headerParams, Map<String, Object> formParams, String accept, String contentType, String[] authNames, TypeRef returnType) throws RestApiException { | |
ClientResponse response = getAPIResponse(path, method, queryParams, body, binaryBody, headerParams, formParams, accept, contentType, authNames); | |
if (response.getStatusInfo() == ClientResponse.Status.NO_CONTENT) { | |
return new RestApiClientResponse<>(response, Optional.<T>absent()); | |
} else if (response.getStatusInfo().getFamily() == Family.SUCCESSFUL) { | |
if (returnType == null) | |
return new RestApiClientResponse<>(response, Optional.<T>absent()); | |
else { | |
T deserialized = deserialize(response, returnType); | |
return new RestApiClientResponse<>(response, Optional.fromNullable(deserialized)); | |
} | |
} else { | |
String message = "error"; | |
String respBody = null; | |
if (response.hasEntity()) { | |
try { | |
respBody = String.valueOf(response.getEntity(String.class)); | |
message = respBody; | |
} catch (RuntimeException e) { | |
// e.printStackTrace(); | |
} | |
} | |
throw new RestApiException( | |
response.getStatusInfo().getStatusCode(), | |
message, | |
response.getHeaders(), | |
respBody); | |
} | |
} | |
/** | |
* Invoke API by sending HTTP request with the given options - return binary result | |
* | |
* @param path The sub-path of the HTTP URL | |
* @param method The request method, one of "GET", "POST", "PUT", and "DELETE" | |
* @param queryParams The query parameters | |
* @param body The request body object - if it is not binary, otherwise null | |
* @param binaryBody The request body object - if it is binary, otherwise null | |
* @param headerParams The header parameters | |
* @param formParams The form parameters | |
* @param accept The request's Accept header | |
* @param contentType The request's Content-Type header | |
* @param authNames The authentications to apply | |
* @return The response body in type of string | |
*/ | |
public byte[] invokeBinaryAPI(String path, String method, List<Pair> queryParams, Object body, byte[] binaryBody, Map<String, String> headerParams, Map<String, Object> formParams, String accept, String contentType, String[] authNames) throws RestApiException { | |
ClientResponse response = getAPIResponse(path, method, queryParams, body, binaryBody, headerParams, formParams, accept, contentType, authNames); | |
if (response.getStatusInfo() == ClientResponse.Status.NO_CONTENT) { | |
return null; | |
} else if (response.getStatusInfo().getFamily() == Family.SUCCESSFUL) { | |
if (response.hasEntity()) { | |
DataInputStream stream = new DataInputStream(response.getEntityInputStream()); | |
byte[] data = new byte[response.getLength()]; | |
try { | |
stream.readFully(data); | |
} catch (IOException ex) { | |
throw new RestApiException(500, "Error obtaining binary response data"); | |
} | |
return data; | |
} else { | |
return new byte[0]; | |
} | |
} else { | |
String message = "error"; | |
if (response.hasEntity()) { | |
try { | |
message = String.valueOf(response.getEntity(String.class)); | |
} catch (RuntimeException e) { | |
// e.printStackTrace(); | |
} | |
} | |
throw new RestApiException( | |
response.getStatusInfo().getStatusCode(), | |
message); | |
} | |
} | |
/** | |
* Encode the given form parameters as request body. | |
*/ | |
private String getXWWWFormUrlencodedParams(Map<String, Object> formParams) { | |
StringBuilder formParamBuilder = new StringBuilder(); | |
for (Entry<String, Object> param : formParams.entrySet()) { | |
String keyStr = param.getKey(); | |
String valueStr = parameterToString(param.getValue()); | |
try { | |
formParamBuilder.append(URLEncoder.encode(param.getKey(), "utf8")) | |
.append("=") | |
.append(URLEncoder.encode(valueStr, "utf8")); | |
formParamBuilder.append("&"); | |
} catch (UnsupportedEncodingException e) { | |
// move on to next | |
} | |
} | |
String encodedFormParams = formParamBuilder.toString(); | |
if (encodedFormParams.endsWith("&")) { | |
encodedFormParams = encodedFormParams.substring(0, encodedFormParams.length() - 1); | |
} | |
return encodedFormParams; | |
} | |
/** | |
* Get an existing client or create a new client to handle HTTP request. | |
*/ | |
private Client getClient() { | |
Client client = hostMap.get(basePath); | |
if (client!=null) return client; | |
client = Client.create(); | |
if (debug) { | |
client.addFilter(new LoggingFilter()); | |
} | |
hostMap.putIfAbsent(basePath, client); | |
return hostMap.get(basePath); //be sure to use the same in case of a race condition. | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment