/*******************************************************************************
* Copyright (c) 2011 GitHub Inc.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* Kevin Sawicki (GitHub Inc.) - initial API and implementation
* Christian Trutz - HttpClient 4.1
*******************************************************************************/
package org.eclipse.egit.github.core.client;
import static com.google.gson.stream.JsonToken.BEGIN_ARRAY;
import static java.net.HttpURLConnection.HTTP_ACCEPTED;
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
import static java.net.HttpURLConnection.HTTP_CONFLICT;
import static java.net.HttpURLConnection.HTTP_CREATED;
import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
import static java.net.HttpURLConnection.HTTP_GONE;
import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
import static java.net.HttpURLConnection.HTTP_OK;
import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
import static org.eclipse.egit.github.core.client.IGitHubConstants.AUTH_TOKEN;
import static org.eclipse.egit.github.core.client.IGitHubConstants.CHARSET_UTF8;
import static org.eclipse.egit.github.core.client.IGitHubConstants.CONTENT_TYPE_JSON;
import static org.eclipse.egit.github.core.client.IGitHubConstants.HOST_API;
import static org.eclipse.egit.github.core.client.IGitHubConstants.HOST_DEFAULT;
import static org.eclipse.egit.github.core.client.IGitHubConstants.HOST_GISTS;
import static org.eclipse.egit.github.core.client.IGitHubConstants.PROTOCOL_HTTPS;
import static org.eclipse.egit.github.core.client.IGitHubConstants.SEGMENT_V3_API;
import com.google.gson.Gson;
import com.google.gson.JsonParseException;
import com.google.gson.stream.JsonReader;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Type;
import java.net.HttpURLConnection;
import java.net.URL;
import org.eclipse.egit.github.core.RequestError;
import org.eclipse.egit.github.core.util.EncodingUtils;
/**
* Client class for interacting with GitHub HTTP/JSON API.
*/
public class GitHubClient {
/**
* Create API v3 client from URL.
* <p>
* This creates an HTTPS-based client with a host that contains the host
* value of the given URL prefixed with 'api' if the given URL is github.com
* or gist.github.com
*
* @param url
* @return client
*/
public static GitHubClient createClient(String url) {
try {
String host = new URL(url).getHost();
if (HOST_DEFAULT.equals(host) || HOST_GISTS.equals(host))
host = HOST_API;
return new GitHubClient(host);
} catch (IOException e) {
throw new IllegalArgumentException(e);
}
}
/**
* Content-Type header
*/
protected static final String HEADER_CONTENT_TYPE = "Content-Type"; //$NON-NLS-1$
/**
* Accept header
*/
protected static final String HEADER_ACCEPT = "Accept"; //$NON-NLS-1$
/**
* Authorization header
*/
protected static final String HEADER_AUTHORIZATION = "Authorization"; //$NON-NLS-1$
/**
* User-Agent header
*/
protected static final String HEADER_USER_AGENT = "User-Agent"; //$NON-NLS-1$
/**
* METHOD_GET
*/
protected static final String METHOD_GET = "GET"; //$NON-NLS-1$
/**
* METHOD_PUT
*/
protected static final String METHOD_PUT = "PUT"; //$NON-NLS-1$
/**
* METHOD_POST
*/
protected static final String METHOD_POST = "POST"; //$NON-NLS-1$
/**
* METHOD_DELETE
*/
protected static final String METHOD_DELETE = "DELETE"; //$NON-NLS-1$
/**
* Default user agent request header value
*/
protected static final String USER_AGENT = "GitHubJava/2.1.0"; //$NON-NLS-1$
/**
* 422 status code for unprocessable entity
*/
protected static final int HTTP_UNPROCESSABLE_ENTITY = 422;
/**
* Base URI
*/
protected final String baseUri;
/**
* Prefix to apply to base URI
*/
protected final String prefix;
/**
* {@link Gson} instance
*/
protected Gson gson = GsonUtils.getGson();
private String user;
private String credentials;
private String userAgent = USER_AGENT;
private int bufferSize = 8192;
private int requestLimit = -1;
private int remainingRequests = -1;
/**
* Create default client
*/
public GitHubClient() {
this(HOST_API);
}
/**
* Create client for host name
*
* @param hostname
*/
public GitHubClient(String hostname) {
this(hostname, -1, PROTOCOL_HTTPS);
}
/**
* Create client for host, port, and scheme
*
* @param hostname
* @param port
* @param scheme
*/
public GitHubClient(final String hostname, final int port,
final String scheme) {
final StringBuilder uri = new StringBuilder(scheme);
uri.append("://"); //$NON-NLS-1$
uri.append(hostname);
if (port > 0)
uri.append(':').append(port);
baseUri = uri.toString();
// Use URI prefix on non-standard host names
if (HOST_API.equals(hostname))
prefix = null;
else
prefix = SEGMENT_V3_API;
}
/**
* Set whether or not serialized data should include fields that are null.
*
* @param serializeNulls
* @return this client
*/
public GitHubClient setSerializeNulls(boolean serializeNulls) {
gson = GsonUtils.getGson(serializeNulls);
return this;
}
/**
* Set the value to set as the user agent header on every request created.
* Specifying a null or empty agent parameter will reset this client to use
* the default user agent header value.
*
* @param agent
* @return this client
*/
public GitHubClient setUserAgent(final String agent) {
if (agent != null && agent.length() > 0)
userAgent = agent;
else
userAgent = USER_AGENT;
return this;
}
/**
* Configure request with standard headers
*
* @param request
* @return configured request
*/
protected HttpURLConnection configureRequest(final HttpURLConnection request) {
if (credentials != null)
request.setRequestProperty(HEADER_AUTHORIZATION, credentials);
request.setRequestProperty(HEADER_USER_AGENT, userAgent);
request.setRequestProperty(HEADER_ACCEPT,
"application/vnd.github.beta+json"); //$NON-NLS-1$
return request;
}
/**
* Configure request URI
*
* @param uri
* @return configured URI
*/
protected String configureUri(final String uri) {
if (prefix == null || uri.startsWith(prefix))
return uri;
else
return prefix + uri;
}
/**
* Create connection to URI
*
* @param uri
* @return connection
* @throws IOException
*/
protected HttpURLConnection createConnection(String uri) throws IOException {
URL url = new URL(createUri(uri));
return (HttpURLConnection) url.openConnection();
}
/**
* Create connection to URI
*
* @param uri
* @param method
* @return connection
* @throws IOException
*/
protected HttpURLConnection createConnection(String uri, String method)
throws IOException {
HttpURLConnection connection = createConnection(uri);
connection.setRequestMethod(method);
return configureRequest(connection);
}
/**
* Create a GET request connection to the URI
*
* @param uri
* @return connection
* @throws IOException
*/
protected HttpURLConnection createGet(String uri) throws IOException {
return createConnection(uri, METHOD_GET);
}
/**
* Create a POST request connection to the URI
*
* @param uri
* @return connection
* @throws IOException
*/
protected HttpURLConnection createPost(String uri) throws IOException {
return createConnection(uri, METHOD_POST);
}
/**
* Create a PUT request connection to the URI
*
* @param uri
* @return connection
* @throws IOException
*/
protected HttpURLConnection createPut(String uri) throws IOException {
return createConnection(uri, METHOD_PUT);
}
/**
* Create a DELETE request connection to the URI
*
* @param uri
* @return connection
* @throws IOException
*/
protected HttpURLConnection createDelete(String uri) throws IOException {
return createConnection(uri, METHOD_DELETE);
}
/**
* Set credentials
*
* @param user
* @param password
* @return this client
*/
public GitHubClient setCredentials(final String user, final String password) {
this.user = user;
if (user != null && user.length() > 0 && password != null
&& password.length() > 0)
credentials = "Basic " //$NON-NLS-1$
+ EncodingUtils.toBase64(user + ':' + password);
else
credentials = null;
return this;
}
/**
* Set OAuth2 token
*
* @param token
* @return this client
*/
public GitHubClient setOAuth2Token(String token) {
if (token != null && token.length() > 0)
credentials = AUTH_TOKEN + ' ' + token;
else
credentials = null;
return this;
}
/**
* Set buffer size used to send the request and read the response
*
* @param bufferSize
* @return this client
*/
public GitHubClient setBufferSize(int bufferSize) {
if (bufferSize < 1)
throw new IllegalArgumentException(
"Buffer size must be greater than zero"); //$NON-NLS-1$
this.bufferSize = bufferSize;
return this;
}
/**
* Get the user that this client is currently authenticating as
*
* @return user or null if not authentication
*/
public String getUser() {
return user;
}
/**
* Convert object to a JSON string
*
* @param object
* @return JSON string
* @throws IOException
*/
protected String toJson(Object object) throws IOException {
try {
return gson.toJson(object);
} catch (JsonParseException jpe) {
IOException ioe = new IOException(
"Parse exception converting object to JSON"); //$NON-NLS-1$
ioe.initCause(jpe);
throw ioe;
}
}
/**
* Parse JSON to specified type
*
* @param <V>
* @param stream
* @param type
* @return parsed type
* @throws IOException
*/
protected <V> V parseJson(InputStream stream, Type type) throws IOException {
return parseJson(stream, type, null);
}
/**
* Parse JSON to specified type
*
* @param <V>
* @param stream
* @param type
* @param listType
* @return parsed type
* @throws IOException
*/
protected <V> V parseJson(InputStream stream, Type type, Type listType)
throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(
stream, CHARSET_UTF8), bufferSize);
if (listType == null)
try {
return gson.fromJson(reader, type);
} catch (JsonParseException jpe) {
IOException ioe = new IOException(
"Parse exception converting JSON to object"); //$NON-NLS-1$
ioe.initCause(jpe);
throw ioe;
} finally {
try {
reader.close();
} catch (IOException ignored) {
// Ignored
}
}
else {
JsonReader jsonReader = new JsonReader(reader);
try {
if (jsonReader.peek() == BEGIN_ARRAY)
return gson.fromJson(jsonReader, listType);
else
return gson.fromJson(jsonReader, type);
} catch (JsonParseException jpe) {
IOException ioe = new IOException(
"Parse exception converting JSON to object"); //$NON-NLS-1$
ioe.initCause(jpe);
throw ioe;
} finally {
try {
jsonReader.close();
} catch (IOException ignored) {
// Ignored
}
}
}
}
/**
* Does status code denote an error
*
* @param code
* @return true if error, false otherwise
*/
protected boolean isError(final int code) {
switch (code) {
case HTTP_BAD_REQUEST:
case HTTP_UNAUTHORIZED:
case HTTP_FORBIDDEN:
case HTTP_NOT_FOUND:
case HTTP_CONFLICT:
case HTTP_GONE:
case HTTP_UNPROCESSABLE_ENTITY:
case HTTP_INTERNAL_ERROR:
return true;
default:
return false;
}
}
/**
* Does status code denote a non-error response?
*
* @param code
* @return true if okay, false otherwise
*/
protected boolean isOk(final int code) {
switch (code) {
case HTTP_OK:
case HTTP_CREATED:
case HTTP_ACCEPTED:
return true;
default:
return false;
}
}
/**
* Is the response empty?
*
* @param code
* @return true if empty, false otherwise
*/
protected boolean isEmpty(final int code) {
return HTTP_NO_CONTENT == code;
}
/**
* Parse error from response
*
* @param response
* @return request error
* @throws IOException
*/
protected RequestError parseError(InputStream response) throws IOException {
return parseJson(response, RequestError.class);
}
/**
* Get body from response inputs stream
*
* @param request
* @param stream
* @return parsed body
* @throws IOException
*/
protected Object getBody(GitHubRequest request, InputStream stream)
throws IOException {
Type type = request.getType();
if (type != null)
return parseJson(stream, type, request.getArrayType());
else
return null;
}
/**
* Create error exception from response and throw it
*
* @param response
* @param code
* @param status
* @return non-null newly created {@link IOException}
*/
protected IOException createException(InputStream response, int code,
String status) {
if (isError(code)) {
final RequestError error;
try {
error = parseError(response);
} catch (IOException e) {
return e;
}
if (error != null)
return new RequestException(error, code);
} else
try {
response.close();
} catch (IOException ignored) {
// Ignored
}
String message;
if (status != null && status.length() > 0)
message = status + " (" + code + ')'; //$NON-NLS-1$
else
message = "Unknown error occurred (" + code + ')'; //$NON-NLS-1$
return new IOException(message);
}
/**
* Post to URI
*
* @param uri
* @throws IOException
*/
public void post(String uri) throws IOException {
post(uri, null, null);
}
/**
* Put to URI
*
* @param uri
* @throws IOException
*/
public void put(String uri) throws IOException {
put(uri, null, null);
}
/**
* Delete resource at URI. This method will throw an {@link IOException}
* when the response status is not a 204 (No Content).
*
* @param uri
* @throws IOException
*/
public void delete(String uri) throws IOException {
delete(uri, null);
}
/**
* Send parameters to output stream of request
*
* @param request
* @param params
* @throws IOException
*/
protected void sendParams(HttpURLConnection request, Object params)
throws IOException {
request.setDoOutput(true);
if (params != null) {
request.setRequestProperty(HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON
+ "; charset=" + CHARSET_UTF8); //$NON-NLS-1$
byte[] data = toJson(params).getBytes(CHARSET_UTF8);
request.setFixedLengthStreamingMode(data.length);
BufferedOutputStream output = new BufferedOutputStream(
request.getOutputStream(), bufferSize);
try {
output.write(data);
output.flush();
} finally {
try {
output.close();
} catch (IOException ignored) {
// Ignored
}
}
} else {
request.setFixedLengthStreamingMode(0);
request.setRequestProperty("Content-Length", "0");
}
}
private <V> V sendJson(final HttpURLConnection request,
final Object params, final Type type) throws IOException {
sendParams(request, params);
final int code = request.getResponseCode();
updateRateLimits(request);
if (isOk(code))
if (type != null)
return parseJson(getStream(request), type);
else
return null;
if (isEmpty(code))
return null;
throw createException(getStream(request), code,
request.getResponseMessage());
}
/**
* Create full URI from path
*
* @param path
* @return uri
*/
protected String createUri(final String path) {
return baseUri + configureUri(path);
}
/**
* Get response stream from GET to URI. It is the responsibility of the
* calling method to close the returned stream.
*
* @param request
* @return stream
* @throws IOException
*/
public InputStream getStream(final GitHubRequest request)
throws IOException {
return getResponseStream(createGet(request.generateUri()));
}
/**
* Get response stream from POST to URI. It is the responsibility of the
* calling method to close the returned stream.
*
* @param uri
* @param params
* @return stream
* @throws IOException
*/
public InputStream postStream(final String uri, final Object params)
throws IOException {
HttpURLConnection connection = createPost(uri);
sendParams(connection, params);
return getResponseStream(connection);
}
/**
* Get response stream for request
*
* @param request
* @return stream
* @throws IOException
*/
protected InputStream getResponseStream(final HttpURLConnection request)
throws IOException {
InputStream stream = getStream(request);
int code = request.getResponseCode();
updateRateLimits(request);
if (isOk(code))
return stream;
else
throw createException(stream, code, request.getResponseMessage());
}
/**
* Get stream from request
*
* @param request
* @return stream
* @throws IOException
*/
protected InputStream getStream(HttpURLConnection request)
throws IOException {
if (request.getResponseCode() < HTTP_BAD_REQUEST)
return request.getInputStream();
else {
InputStream stream = request.getErrorStream();
return stream != null ? stream : request.getInputStream();
}
}
/**
* Get response from URI and bind to specified type
*
* @param request
* @return response
* @throws IOException
*/
public GitHubResponse get(GitHubRequest request) throws IOException {
HttpURLConnection httpRequest = createGet(request.generateUri());
String accept = request.getResponseContentType();
if (accept != null)
httpRequest.setRequestProperty(HEADER_ACCEPT, accept);
final int code = httpRequest.getResponseCode();
updateRateLimits(httpRequest);
if (isOk(code))
return new GitHubResponse(httpRequest, getBody(request,
getStream(httpRequest)));
if (isEmpty(code))
return new GitHubResponse(httpRequest, null);
throw createException(getStream(httpRequest), code,
httpRequest.getResponseMessage());
}
/**
* Post data to URI
*
* @param <V>
* @param uri
* @param params
* @param type
* @return response
* @throws IOException
*/
public <V> V post(final String uri, final Object params, final Type type)
throws IOException {
HttpURLConnection request = createPost(uri);
return sendJson(request, params, type);
}
/**
* Put data to URI
*
* @param <V>
* @param uri
* @param params
* @param type
* @return response
* @throws IOException
*/
public <V> V put(final String uri, final Object params, final Type type)
throws IOException {
HttpURLConnection request = createPut(uri);
return sendJson(request, params, type);
}
/**
* Delete resource at URI. This method will throw an {@link IOException}
* when the response status is not a 204 (No Content).
*
* @param uri
* @param params
* @throws IOException
*/
public void delete(final String uri, final Object params)
throws IOException {
HttpURLConnection request = createDelete(uri);
if (params != null)
sendParams(request, params);
final int code = request.getResponseCode();
updateRateLimits(request);
if (!isEmpty(code))
throw new RequestException(parseError(getStream(request)), code);
}
/**
* Update rate limits present in response headers
*
* @param request
* @return this client
*/
protected GitHubClient updateRateLimits(HttpURLConnection request) {
String limit = request.getHeaderField("X-RateLimit-Limit");
if (limit != null && limit.length() > 0)
try {
requestLimit = Integer.parseInt(limit);
} catch (NumberFormatException nfe) {
requestLimit = -1;
}
else
requestLimit = -1;
String remaining = request.getHeaderField("X-RateLimit-Remaining");
if (remaining != null && remaining.length() > 0)
try {
remainingRequests = Integer.parseInt(remaining);
} catch (NumberFormatException nfe) {
remainingRequests = -1;
}
else
remainingRequests = -1;
return this;
}
/**
* Get number of requests remaining before rate limiting occurs
* <p>
* This will be the value of the 'X-RateLimit-Remaining' header from the
* last request made
*
* @return remainingRequests or -1 if not present in the response
*/
public int getRemainingRequests() {
return remainingRequests;
}
/**
* Get number of requests that {@link #getRemainingRequests()} counts down
* from as each request is made
* <p>
* This will be the value of the 'X-RateLimit-Limit' header from the last
* request made
*
* @return requestLimit or -1 if not present in the response
*/
public int getRequestLimit() {
return requestLimit;
}
}