diff options
Diffstat (limited to 'plugins/github/src/org/jetbrains/plugins/github/api/GithubConnection.java')
-rw-r--r-- | plugins/github/src/org/jetbrains/plugins/github/api/GithubConnection.java | 491 |
1 files changed, 491 insertions, 0 deletions
diff --git a/plugins/github/src/org/jetbrains/plugins/github/api/GithubConnection.java b/plugins/github/src/org/jetbrains/plugins/github/api/GithubConnection.java new file mode 100644 index 000000000000..6358b79e38ee --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/api/GithubConnection.java @@ -0,0 +1,491 @@ +package org.jetbrains.plugins.github.api; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.util.net.HttpConfigurable; +import com.intellij.util.net.ssl.CertificateManager; +import org.apache.http.*; +import org.apache.http.auth.AuthScope; +import org.apache.http.auth.Credentials; +import org.apache.http.auth.UsernamePasswordCredentials; +import org.apache.http.client.CredentialsProvider; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.*; +import org.apache.http.client.protocol.HttpClientContext; +import org.apache.http.config.ConnectionConfig; +import org.apache.http.conn.ssl.X509HostnameVerifier; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.auth.BasicScheme; +import org.apache.http.impl.client.BasicCredentialsProvider; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.message.BasicHeader; +import org.apache.http.protocol.HttpContext; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; +import org.jetbrains.plugins.github.exceptions.*; +import org.jetbrains.plugins.github.util.GithubAuthData; +import org.jetbrains.plugins.github.util.GithubSettings; +import org.jetbrains.plugins.github.util.GithubUrlUtil; +import org.jetbrains.plugins.github.util.GithubUtil; +import sun.security.validator.ValidatorException; + +import javax.net.ssl.SSLHandshakeException; +import java.awt.*; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.util.*; +import java.util.List; + +import static org.jetbrains.plugins.github.api.GithubApiUtil.createDataFromRaw; +import static org.jetbrains.plugins.github.api.GithubApiUtil.fromJson; + +public class GithubConnection { + private static final Logger LOG = GithubUtil.LOG; + + private static final HttpRequestInterceptor PREEMPTIVE_BASIC_AUTH = new PreemptiveBasicAuthInterceptor(); + + @NotNull private final String myHost; + @NotNull private final CloseableHttpClient myClient; + private final boolean myReusable; + + private volatile HttpUriRequest myRequest; + private volatile boolean myAborted; + + @TestOnly + public GithubConnection(@NotNull GithubAuthData auth) { + this(auth, false); + } + + public GithubConnection(@NotNull GithubAuthData auth, boolean reusable) { + myHost = auth.getHost(); + myClient = createClient(auth); + myReusable = reusable; + } + + private enum HttpVerb { + GET, POST, DELETE, HEAD, PATCH + } + + @Nullable + public JsonElement getRequest(@NotNull String path, + @NotNull Header... headers) throws IOException { + return request(path, null, Arrays.asList(headers), HttpVerb.GET).getJsonElement(); + } + + @Nullable + public JsonElement postRequest(@NotNull String path, + @Nullable String requestBody, + @NotNull Header... headers) throws IOException { + return request(path, requestBody, Arrays.asList(headers), HttpVerb.POST).getJsonElement(); + } + + @Nullable + public JsonElement patchRequest(@NotNull String path, + @Nullable String requestBody, + @NotNull Header... headers) throws IOException { + return request(path, requestBody, Arrays.asList(headers), HttpVerb.PATCH).getJsonElement(); + } + + @Nullable + public JsonElement deleteRequest(@NotNull String path, + @NotNull Header... headers) throws IOException { + return request(path, null, Arrays.asList(headers), HttpVerb.DELETE).getJsonElement(); + } + + @NotNull + public Header[] headRequest(@NotNull String path, + @NotNull Header... headers) throws IOException { + return request(path, null, Arrays.asList(headers), HttpVerb.HEAD).getHeaders(); + } + + public void abort() { + if (myAborted) return; + myAborted = true; + + HttpUriRequest request = myRequest; + if (request != null) request.abort(); + } + + public void close() throws IOException { + myClient.close(); + } + + @NotNull + private static CloseableHttpClient createClient(@NotNull GithubAuthData auth) { + HttpClientBuilder builder = HttpClients.custom(); + + return builder + .setDefaultRequestConfig(createRequestConfig(auth)) + .setDefaultConnectionConfig(createConnectionConfig(auth)) + .setDefaultCredentialsProvider(createCredentialsProvider(auth)) + .setDefaultHeaders(createHeaders(auth)) + .addInterceptorFirst(PREEMPTIVE_BASIC_AUTH) + .setSslcontext(CertificateManager.getInstance().getSslContext()) + .setHostnameVerifier((X509HostnameVerifier)CertificateManager.HOSTNAME_VERIFIER) + .build(); + } + + @NotNull + private static RequestConfig createRequestConfig(@NotNull GithubAuthData auth) { + RequestConfig.Builder builder = RequestConfig.custom(); + + int timeout = GithubSettings.getInstance().getConnectionTimeout(); + builder + .setConnectTimeout(timeout) + .setSocketTimeout(timeout); + + final HttpConfigurable proxySettings = HttpConfigurable.getInstance(); + if (auth.isUseProxy() && proxySettings.USE_HTTP_PROXY && !StringUtil.isEmptyOrSpaces(proxySettings.PROXY_HOST)) { + builder + .setProxy(new HttpHost(proxySettings.PROXY_HOST, proxySettings.PROXY_PORT)); + } + + return builder.build(); + } + + @NotNull + private static ConnectionConfig createConnectionConfig(@NotNull GithubAuthData auth) { + return ConnectionConfig.custom() + .setCharset(Consts.UTF_8) + .build(); + } + + + @NotNull + private static CredentialsProvider createCredentialsProvider(@NotNull GithubAuthData auth) { + CredentialsProvider provider = new BasicCredentialsProvider(); + // Basic authentication + GithubAuthData.BasicAuth basicAuth = auth.getBasicAuth(); + if (basicAuth != null) { + provider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(basicAuth.getLogin(), basicAuth.getPassword())); + } + + final HttpConfigurable proxySettings = HttpConfigurable.getInstance(); + //proxySettings.USE_HTTP_PROXY + if (auth.isUseProxy() && proxySettings.USE_HTTP_PROXY && !StringUtil.isEmptyOrSpaces(proxySettings.PROXY_HOST)) { + if (proxySettings.PROXY_AUTHENTICATION) { + provider.setCredentials(new AuthScope(proxySettings.PROXY_HOST, proxySettings.PROXY_PORT), + new UsernamePasswordCredentials(proxySettings.PROXY_LOGIN, proxySettings.getPlainProxyPassword())); + } + } + return provider; + } + + @NotNull + private static Collection<? extends Header> createHeaders(@NotNull GithubAuthData auth) { + List<Header> headers = new ArrayList<Header>(); + GithubAuthData.TokenAuth tokenAuth = auth.getTokenAuth(); + if (tokenAuth != null) { + headers.add(new BasicHeader("Authorization", "token " + tokenAuth.getToken())); + } + GithubAuthData.BasicAuth basicAuth = auth.getBasicAuth(); + if (basicAuth != null && basicAuth.getCode() != null) { + headers.add(new BasicHeader("X-GitHub-OTP", basicAuth.getCode())); + } + return headers; + } + + @NotNull + private ResponsePage request(@NotNull String path, + @Nullable String requestBody, + @NotNull Collection<Header> headers, + @NotNull HttpVerb verb) throws IOException { + if (myAborted) throw new GithubOperationCanceledException(); + + if (EventQueue.isDispatchThread() && !ApplicationManager.getApplication().isUnitTestMode()) { + LOG.warn("Network operation in EDT"); // TODO: fix + } + + CloseableHttpResponse response = null; + try { + String uri = GithubUrlUtil.getApiUrl(myHost) + path; + response = doREST(uri, requestBody, headers, verb); + + if (myAborted) throw new GithubOperationCanceledException(); + + checkStatusCode(response, requestBody); + + HttpEntity entity = response.getEntity(); + if (entity == null) { + return createResponse(response); + } + + JsonElement ret = parseResponse(entity.getContent()); + if (ret.isJsonNull()) { + return createResponse(response); + } + + String newPath = null; + Header pageHeader = response.getFirstHeader("Link"); + if (pageHeader != null) { + for (HeaderElement element : pageHeader.getElements()) { + NameValuePair rel = element.getParameterByName("rel"); + if (rel != null && "next".equals(rel.getValue())) { + String urlString = element.toString(); + int begin = urlString.indexOf('<'); + int end = urlString.lastIndexOf('>'); + if (begin == -1 || end == -1) { + LOG.error("Invalid 'Link' header", "{" + pageHeader.toString() + "}"); + break; + } + + String url = urlString.substring(begin + 1, end); + String newUrl = GithubUrlUtil.removeProtocolPrefix(url); + int index = newUrl.indexOf('/'); + newPath = newUrl.substring(index); + break; + } + } + } + + return createResponse(ret, newPath, response); + } + catch (SSLHandshakeException e) { // User canceled operation from CertificateManager + if (e.getCause() instanceof ValidatorException) { + LOG.info("Host SSL certificate is not trusted", e); + throw new GithubOperationCanceledException("Host SSL certificate is not trusted", e); + } + throw e; + } + catch (IOException e) { + if (myAborted) throw new GithubOperationCanceledException("Operation canceled", e); + throw e; + } + finally { + myRequest = null; + if (response != null) { + response.close(); + } + if (!myReusable) { + myClient.close(); + } + } + } + + @NotNull + private CloseableHttpResponse doREST(@NotNull final String uri, + @Nullable final String requestBody, + @NotNull final Collection<Header> headers, + @NotNull final HttpVerb verb) throws IOException { + HttpRequestBase request; + switch (verb) { + case POST: + request = new HttpPost(uri); + if (requestBody != null) { + ((HttpPost)request).setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON)); + } + break; + case PATCH: + request = new HttpPatch(uri); + if (requestBody != null) { + ((HttpPatch)request).setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON)); + } + break; + case GET: + request = new HttpGet(uri); + break; + case DELETE: + request = new HttpDelete(uri); + break; + case HEAD: + request = new HttpHead(uri); + break; + default: + throw new IllegalStateException("Unknown HttpVerb: " + verb.toString()); + } + + for (Header header : headers) { + request.addHeader(header); + } + + myRequest = request; + return myClient.execute(request); + } + + private static void checkStatusCode(@NotNull CloseableHttpResponse response, @Nullable String body) throws IOException { + int code = response.getStatusLine().getStatusCode(); + switch (code) { + case HttpStatus.SC_OK: + case HttpStatus.SC_CREATED: + case HttpStatus.SC_ACCEPTED: + case HttpStatus.SC_NO_CONTENT: + return; + case HttpStatus.SC_UNAUTHORIZED: + case HttpStatus.SC_PAYMENT_REQUIRED: + case HttpStatus.SC_FORBIDDEN: + String message = getErrorMessage(response); + + Header headerOTP = response.getFirstHeader("X-GitHub-OTP"); + if (headerOTP != null) { + for (HeaderElement element : headerOTP.getElements()) { + if ("required".equals(element.getName())) { + throw new GithubTwoFactorAuthenticationException(message); + } + } + } + + if (message.contains("API rate limit exceeded")) { + throw new GithubRateLimitExceededException(message); + } + + throw new GithubAuthenticationException("Request response: " + message); + case HttpStatus.SC_BAD_REQUEST: + case HttpStatus.SC_UNPROCESSABLE_ENTITY: + if (body != null) { + LOG.info(body); + } + throw new GithubStatusCodeException(code + ": " + getErrorMessage(response), code); + default: + throw new GithubStatusCodeException(code + ": " + getErrorMessage(response), code); + } + } + + @NotNull + private static String getErrorMessage(@NotNull CloseableHttpResponse response) { + try { + HttpEntity entity = response.getEntity(); + if (entity != null) { + GithubErrorMessageRaw error = fromJson(parseResponse(entity.getContent()), GithubErrorMessageRaw.class); + return response.getStatusLine().getReasonPhrase() + " - " + error.getMessage(); + } + } + catch (IOException e) { + LOG.info(e); + } + return response.getStatusLine().getReasonPhrase(); + } + + @NotNull + private static JsonElement parseResponse(@NotNull InputStream githubResponse) throws IOException { + Reader reader = new InputStreamReader(githubResponse, "UTF-8"); + try { + return new JsonParser().parse(reader); + } + catch (JsonParseException jse) { + throw new GithubJsonException("Couldn't parse GitHub response", jse); + } + finally { + reader.close(); + } + } + + public static class PagedRequest<T> { + @Nullable private String myNextPage; + @NotNull private final Collection<Header> myHeaders; + @NotNull private final Class<T> myResult; + @NotNull private final Class<? extends DataConstructor[]> myRawArray; + + @SuppressWarnings("NullableProblems") + public PagedRequest(@NotNull String path, + @NotNull Class<T> result, + @NotNull Class<? extends DataConstructor[]> rawArray, + @NotNull Header... headers) { + myNextPage = path; + myResult = result; + myRawArray = rawArray; + myHeaders = Arrays.asList(headers); + } + + @NotNull + public List<T> next(@NotNull GithubConnection connection) throws IOException { + if (myNextPage == null) { + throw new NoSuchElementException(); + } + + String page = myNextPage; + myNextPage = null; + + ResponsePage response = connection.request(page, null, myHeaders, HttpVerb.GET); + + if (response.getJsonElement() == null) { + throw new GithubConfusingException("Empty response"); + } + + if (!response.getJsonElement().isJsonArray()) { + throw new GithubJsonException("Wrong json type: expected JsonArray", new Exception(response.getJsonElement().toString())); + } + + myNextPage = response.getNextPage(); + + List<T> result = new ArrayList<T>(); + for (DataConstructor raw : fromJson(response.getJsonElement().getAsJsonArray(), myRawArray)) { + result.add(createDataFromRaw(raw, myResult)); + } + return result; + } + + public boolean hasNext() { + return myNextPage != null; + } + + @NotNull + public List<T> getAll(@NotNull GithubConnection connection) throws IOException { + List<T> result = new ArrayList<T>(); + while (hasNext()) { + result.addAll(next(connection)); + } + return result; + } + } + + private ResponsePage createResponse(@NotNull CloseableHttpResponse response) throws GithubOperationCanceledException { + if (myAborted) throw new GithubOperationCanceledException(); + + return new ResponsePage(null, null, response.getAllHeaders()); + } + + private ResponsePage createResponse(@NotNull JsonElement ret, @Nullable String path, @NotNull CloseableHttpResponse response) + throws GithubOperationCanceledException { + if (myAborted) throw new GithubOperationCanceledException(); + + return new ResponsePage(ret, path, response.getAllHeaders()); + } + + private static class ResponsePage { + @Nullable private final JsonElement myResponse; + @Nullable private final String myNextPage; + @NotNull private final Header[] myHeaders; + + public ResponsePage(@Nullable JsonElement response, @Nullable String next, @NotNull Header[] headers) { + myResponse = response; + myNextPage = next; + myHeaders = headers; + } + + @Nullable + public JsonElement getJsonElement() { + return myResponse; + } + + @Nullable + public String getNextPage() { + return myNextPage; + } + + @NotNull + public Header[] getHeaders() { + return myHeaders; + } + } + + private static class PreemptiveBasicAuthInterceptor implements HttpRequestInterceptor { + @Override + public void process(HttpRequest request, HttpContext context) throws HttpException, IOException { + CredentialsProvider provider = (CredentialsProvider)context.getAttribute(HttpClientContext.CREDS_PROVIDER); + Credentials credentials = provider.getCredentials(AuthScope.ANY); + if (credentials != null) { + request.addHeader(new BasicScheme(Consts.UTF_8).authenticate(credentials, request, context)); + } + } + } +} |