| 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)); |
| } |
| } |
| } |
| } |