All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.keeber.http.Rest Maven / Gradle / Ivy

There is a newer version: 0.9.5
Show newest version
package org.keeber.http;

import java.io.BufferedOutputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Type;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.ProtocolException;
import java.net.URL;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;

public abstract class Rest {
  protected String url;
  protected Map headers = new HashMap();
  protected static final String AUTH = "Authorization";

  /**
   * Create a client instance with the given endpoint.
   * 
   * @param url the endpoint (typically starting with http:// or https://)
   * @return Client
   */
  public static Client newClient(String url) {
    return new Client(url);
  }

  protected Rest(String url) {
    this.url = url;
  }

  private Gson serializer;

  Gson serializer() {
    return this.serializer == null ? serializer = new GsonBuilder().setPrettyPrinting().create() : serializer;
  }

  /**
   * Set the Gson serializer for object operations.
   * 
   * @param serializer Gson instance to use.
   * @return Rest
   */
  public T serializer(Gson serializer) {
    this.serializer = serializer;
    return getThis();
  }

  public T header(String key, String value) {
    headers.put(key, value);
    return getThis();
  }

  protected abstract T getThis();

  /**
   * Removed the designated header from this client (already created requests will be unaffected).
   * 
   * @param key header key to remove eg: "User-agent"
   * @return Rest
   */
  public T clear(String key) {
    headers.remove(key);
    return getThis();
  }

  /**
   * Clear the basic authorization for this client.
   * 
   * @return Rest
   */
  public T nobasic() {
    return clear(AUTH);
  }

  /**
   * Set basic authorization for this client.
   * 
   * @param username User name.
   * @param password Password.
   * @return Rest
   */
  public T basic(String username, String password) {
    return header(AUTH, "Basic " + io.encode(username + ":" + password));
  }

  /**
   * Sets headers for JSON communication (Accept & Content-Type).
   * 
   * @return Rest
   */
  public T json() {
    header("Accept", "application/json");
    header("Content-Type", "application/json");
    return getThis();
  }

  private enum Method {
    POST, GET, PUT, DELETE, HEAD;
  }

  public static class Client extends Rest {

    /**
     * Create a request from this client. The provided URL is appended to the clients endpoint - it
     * may also include route params. Headers from the client (including auth) will be used on
     * execution.
     * 
     * 

* Requests executions (get(), post(), etc... methods) are thread safe. * * @param url the endpoint. * @return Request */ public Request newRequest(String url) { return new Request(url); } /** * Create a request from this client. Using the base URL for the client. Headers from the client * (including auth) will be used on execution. * *

* Requests executions (get(), post(), etc... methods) are thread safe. * * @param url the endpoint. * @return Request */ public Request newRequest() { return new Request(""); } private Client(String url) { super(url); } public class Request extends Rest { private Request(String url) { super(url); } private String query; @Override protected Request getThis() { return this; } /** * Add query parameters to this request - this method will perform URL encoding. * *

* Query parameters are cumulative (ie: they do not use an underlying map) - however them may * contain (or consist entirely of) route parameters in the form of {myparam}. * * @param name of the parameter * @param value of the parameter * @return Request */ public Request query(String name, String value) { try { query = (query == null ? "?" : query + "&") + name + "=" + URLEncoder.encode(value, io.UTF_8); } catch (UnsupportedEncodingException e) { throw new RuntimeException(e.getMessage(), e); } return this; } /** * Add query parameters to this request - this method delegates to {@link #query(String, String)} * *

* When the Optiona value is empty the parameter is ommited from the Request. * * @param name of the parameter * @param value (optional) of the parameter - allowing it to be null * @return Request */ public Request query(String name, Optional value) { if (value.isPresent()) { return query(name, value.get().toString()); } return this; } /** * Execute a GET with the specified route params. * *

* To replace the route param {id} in the URL (or query string) the route params * "id","myidvalue" would be provided. * * @param routes optional string list of routes. * @return Response * @throws RestException */ public Response get(String... routes) throws RestException { return execute(this, null, Method.GET, routes); } /** * Execute a HEAD with the specified route params. * * @param routes optional string list of routes. * @return Response * @throws RestException */ public Response head(String... routes) throws RestException { return execute(this, null, Method.HEAD, routes); } /** * Execute a POST with the provided object as the body with the optional route params. * * @param body for the post - can be a Rest.Form object for a multi-part post. * @param routes optional string list of routes. * @return Response * @throws RestException */ public Response post(Object body, String... routes) throws RestException { return execute(this, body, Method.POST, routes); } /** * Execute a PUT with the provided object as the body with the optional route params. * * @param body for the post - can be a Rest.Form object for a multi-part post. * @param type expected return type or class. * @param routes optional string list of routes. * @return Response * @throws RestException */ public Response put(Object body, String... routes) throws RestException { return execute(this, body, Method.PUT, routes); } /** * Execute a DELETE with the optional route params. * * @param type expected return type or class. * @param routes optional string list of routes. * @return Response * @throws RestException */ public Response delete(String... routes) throws RestException { return execute(this, null, Method.DELETE, routes); } } /** * An HTTP response class. * * @author Jason Keeber * * @param */ public class Response { private Object result; private int code, length; private String message, contentType; /** * Does this response contain a non-null value for type T? * * @return true is the result is present. */ public boolean hasResult() { return result != null; } public JsonObject asJsonObject() throws RestException { return as(JsonObject.class); } public String asString() throws RestException { return as(String.class); } public InputStream asStream() throws RestException { return as(InputStream.class); } /** * In the case that the result of the call is closeable - it will close the stream - which * will release the underlying connection. This might need to be called for certain responses * that contain no content or no content type - as they will appear to be streaming. * * @return this */ public Response dispose() { if (result instanceof Closeable) { io.close((Closeable) result); } return this; } /** * Converts the result (if present) to the requested type (if necessary). Possible types are * String (for text responses), JsonElement (for json content types), and InputStream (for all * other types). * *

* JsonElement types can be converted to other types as appropriate. * * @param type * @return * @throws RestException */ @SuppressWarnings("unchecked") public T as(Type type) throws RestException { if (result == null) { return null; } if (result instanceof InputStream) { if (type.equals(InputStream.class)) { return (T) result; } throw new RestException("Cannot convert streaming response type [" + type.getTypeName() + "]"); } if (result instanceof JsonElement) { if (type.equals(String.class)) { return (T) serializer().toJson(result); } if (type.equals(JsonArray.class)) { return (T) ((JsonElement) result).getAsJsonArray(); } if (type.equals(JsonObject.class)) { return (T) ((JsonElement) result).getAsJsonObject(); } if (type.equals(JsonElement.class)) { return (T) result; } return serializer().fromJson((JsonElement) result, type); } else { if (type.equals(String.class)) { return (T) result.toString(); } return serializer().fromJson(result.toString(), type); } } /** * The code of the response ie: 200 == OK * * @return HTTP response code. */ public int getCode() { return code; } /** * The contents length (if available). * * @return the content length. */ public int getLength() { return length; } /** * The response message. * * @return eg: OK */ public String getMessage() { return message; } /** * The content type of the response. * * @return */ public String getContentType() { return contentType; } } @Override protected Client getThis() { return this; } protected Response execute(Request request, Object body, Method method, String... routes) throws RestException { Response response = new Response(); HttpURLConnection connection = null; Payload payload = null; if (body != null) { if (body instanceof Payload) { payload = (Payload) body; } else if (body instanceof InputStream) { payload = io.newPayload((InputStream) body); } else if (body instanceof String) { payload = io.newPayload((String) body); } else { payload = io.newPayload(request.serializer().toJson(body), "application/json"); } } boolean streaming = false; try { // Swap the route params into the url String furl = new StringBuilder(url).append(request.url).append(request.query == null ? "" : request.query).toString(); if (routes.length % 2 != 0) { throw new RestException("Even number of route paramaters expected [" + routes.length + "]"); } for (int i = 0; i < routes.length; i += 2) { furl = furl.replaceAll("(%7B|\\{)" + routes[i] + "(%7D|\\})", URLEncoder.encode(routes[i + 1], io.UTF_8)); } // Create the connection & set the method connection = (HttpURLConnection) new URL(furl).openConnection(); connection.setRequestMethod(method.toString()); // Set the content type - which might be overridden by one of the headers. if (payload != null) { connection.setRequestProperty("Content-Type", payload.getContentType()); } // Set the headers { for (Entry header : headers.entrySet()) { connection.setRequestProperty(header.getKey(), header.getValue()); } for (Entry header : request.headers.entrySet()) { connection.setRequestProperty(header.getKey(), header.getValue()); } // If the content is streamable we set stream mode. if (payload != null && (payload.isStreamable() && payload.getLength() > 0)) { connection.setFixedLengthStreamingMode(payload.getLength()); connection.setRequestProperty("Content-length", payload.getLength() + ""); } } connection.setDoInput(true); connection.setDoOutput(body != null); connection.connect(); // Request BODY if (payload != null) { payload.write(new BufferedOutputStream(connection.getOutputStream())); } response.code = connection.getResponseCode(); response.message = connection.getResponseMessage(); response.length = connection.getContentLength(); response.contentType = connection.getContentType(); if (response.code >= 200 && response.code < 400) { if (response.contentType != null && response.contentType.matches(".*(text|json).*")) { String result = io.asString(connection.getInputStream()); response.result = (response.contentType.contains("text") ? result : request.serializer().fromJson(result, JsonElement.class)); } else { response.result = new AutocloseConnectionStream(connection, connection.getInputStream()); streaming = true; } } } catch (ProtocolException e) { throw new RestException(e.getMessage(), e); } catch (MalformedURLException e) { throw new RestException(e.getMessage(), e); } catch (IOException e) { throw new RestException(e.getMessage(), e); } finally { if (!streaming && connection != null) { connection.disconnect(); } } return response; } } /** * A stream that disconnection the Http URL Connection when it is closed. * * @author Jason Keeber * */ private static class AutocloseConnectionStream extends FilterInputStream { private transient HttpURLConnection connection; public AutocloseConnectionStream(HttpURLConnection connection, InputStream in) { super(in); this.connection = connection; } @Override public void close() throws IOException { super.close(); connection.disconnect(); } } /** * Oh noes...it haz a went wrong. * * @author Jason Keeber * */ public static class RestException extends Exception { public RestException(String message, Throwable cause) { super(message, cause); } public RestException(String message) { super(message); } } /** * IO utility class. * * @author Jason Keeber * */ public static class io { private static class StringPayload implements Payload { private String content; private String contentType = "text/plain"; private StringPayload(String content) { this.content = content; } private StringPayload(String content, String contentType) { this.content = content; this.contentType = contentType; } @Override public boolean isStreamable() { return true; } @Override public long getLength() { return content.getBytes().length; } @Override public void write(OutputStream os) throws IOException { io.copy(new ByteArrayInputStream(content.getBytes()), os, true); } @Override public String getContentType() { return contentType; } } private static class InputStreamPayload implements Payload { private InputStream is; private String contentType = "application/octet-stream"; private long length = -1; private InputStreamPayload(InputStream is) { this.is = is; } private InputStreamPayload(InputStream is, long length) { this.is = is; this.length = length; } private InputStreamPayload(InputStream is, long length, String contentType) { this.is = is; this.length = length; this.contentType = contentType; } private InputStreamPayload(InputStream is, String contentType) { this.is = is; this.contentType = contentType; } @Override public boolean isStreamable() { return length > 0; } @Override public long getLength() { return length; } @Override public void write(OutputStream os) throws IOException { io.copy(is, os, true); } @Override public String getContentType() { return contentType; } } public static Payload newPayload(String content, String contentType) { return new StringPayload(content, contentType); } public static Payload newPayload(String content) { return new StringPayload(content); } public static Payload newPayload(InputStream is, long length, String contentType) { return new InputStreamPayload(is, length, contentType); } public static Payload newPayload(InputStream is, long length) { return new InputStreamPayload(is, length); } public static Payload newPayload(InputStream is, String contentType) { return new InputStreamPayload(is, contentType); } public static Payload newPayload(InputStream is) { return new InputStreamPayload(is); } private static final String UTF_8 = "UTF-8"; private static final String LF = "\r\n"; public static void copy(InputStream is, OutputStream os, boolean close) throws IOException { try { byte[] buffer = new byte[1024 * 16]; int len; while ((len = is.read(buffer)) > 0) { os.write(buffer, 0, len); } os.flush(); } finally { if (close) { io.close(is); io.close(os); } } } public static String asString(InputStream is) throws IOException { ByteArrayOutputStream bos = new ByteArrayOutputStream(); copy(is, bos, true); return bos.toString(UTF_8); } private static void close(Closeable stream) { if (stream != null) { try { stream.close(); } catch (IOException e) { // Ignore (that is the only function of this method. } } } private static String encode(String content) { byte[] data; try { data = content.getBytes(UTF_8); } catch (UnsupportedEncodingException e) { throw new RuntimeException(e.getMessage(), e); } char[] tbl = {'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/'}; StringBuffer buffer = new StringBuffer(); int pad = 0; for (int i = 0; i < data.length; i += 3) { int b = ((data[i] & 0xFF) << 16) & 0xFFFFFF; if (i + 1 < data.length) { b |= (data[i + 1] & 0xFF) << 8; } else { pad++; } if (i + 2 < data.length) { b |= (data[i + 2] & 0xFF); } else { pad++; } for (int j = 0; j < 4 - pad; j++) { int c = (b & 0xFC0000) >> 18; buffer.append(tbl[c]); b <<= 6; } } for (int j = 0; j < pad; j++) { buffer.append("="); } return buffer.toString(); } } /** * A HTTP Form representation. * * @author Jason Keeber * */ public static class MultipartForm implements Payload { private static class Part { private InputStream is; private long length; private Part(String content) { try { byte[] bytes = content.getBytes(io.UTF_8); this.length = bytes.length; this.is = new ByteArrayInputStream(bytes); } catch (UnsupportedEncodingException e) { // } } private Part(InputStream is, long length) { this.is = is; this.length = length; } } private String boundary = "AEX0908096763745435x0"; private String tail = "--" + boundary + "--" + io.LF; private boolean streamable = true; private long length = 0; private List parts = new LinkedList(); private MultipartForm add(Part part) { if (part.length < 0) { streamable = false; } length = length + part.length; parts.add(part); return this; } /** * Add the object to this form with the provided key. None-string objects will be serialized * (using the Rest client serializer) to JSON objects when the form is POSTed. * * @param key the form name. * @param value the form value. * @return Form */ public MultipartForm add(String key, String value) { StringBuffer b = new StringBuffer(); b.append("--" + boundary).append(io.LF); b.append("Content-Disposition: form-data; name=\"").append(key).append("\"").append(io.LF); b.append("Content-Type: text/plain; charset=").append(io.UTF_8).append(io.LF).append(io.LF); try { b.append(URLEncoder.encode(value, io.UTF_8)); } catch (UnsupportedEncodingException e) { throw new RuntimeException(e); } b.append(io.LF); return add(new Part(b.toString())); } public MultipartForm add(String key, String filename, InputStream stream) { return add(key, filename, stream, -1); } /** * Add the provided stream as a file to the Form. There is another method without the length if * it is unknown - BUT it must be present to enable true streaming. * * @param key the form name. * @param filename the file upload name. * @param stream the file content. * @param length of the file content. * @return Form */ public MultipartForm add(String key, String filename, InputStream stream, long length) { StringBuffer b = new StringBuffer(); b.append("--" + boundary).append(io.LF); b.append("Content-Disposition: form-data; name=\"").append(key).append("\"; filename=\""); try { b.append(URLEncoder.encode(filename, io.UTF_8)).append("\"").append(io.LF); } catch (UnsupportedEncodingException e) { throw new RuntimeException(e); } b.append("Content-Type: ").append(HttpURLConnection.guessContentTypeFromName(filename)).append(io.LF); b.append("Content-Transfer-Encoding: binary").append(io.LF).append(io.LF); add(new Part(b.toString())); add(new Part(stream, length)); return add(new Part(io.LF + "")); } /** * Add the provided string content as a file upload to the Form. * * @param key the form name. * @param filename the file upload name. * @param filecontent string file content. * @return Form */ public MultipartForm add(String key, String filename, String content) { try { byte[] file = content.getBytes(io.UTF_8); return add(key, filename, new ByteArrayInputStream(file), file.length); } catch (UnsupportedEncodingException e) { return null; } } @Override public boolean isStreamable() { return streamable; } @Override public long getLength() { try { return length + tail.getBytes(io.UTF_8).length; } catch (UnsupportedEncodingException e) { return 0; } } @Override public void write(OutputStream os) throws IOException { for (Part part : parts) { io.copy(part.is, os, false); } os.write(tail.getBytes(io.UTF_8)); os.flush(); os.close(); } @Override public String getContentType() { return "multipart/form-data; boundary=" + boundary; } } public static class XForm implements Payload { private String data = ""; public XForm add(String key, String value) { try { data = (data.length() > 0 ? data + "&" : "") + key + "=" + URLEncoder.encode(value, io.UTF_8); } catch (UnsupportedEncodingException e) { throw new RuntimeException(e.getMessage(), e); } return this; } @Override public long getLength() { try { return data.getBytes(io.UTF_8).length; } catch (UnsupportedEncodingException e) { throw new RuntimeException(e); } } @Override public void write(OutputStream os) throws IOException { io.copy(new ByteArrayInputStream(data.getBytes(io.UTF_8)), os, true); } @Override public String getContentType() { return "application/x-www-form-urlencoded"; } @Override public boolean isStreamable() { return true; } } /** * A basic payload for HTTP post / put methods - really used to determine content length to turn * on actual streaming (ie: not cached). * * @author Jason Keeber * */ private static interface Payload { public boolean isStreamable(); public long getLength(); public void write(OutputStream os) throws IOException; public String getContentType(); } }