blob: 6358b79e38eed9cc16d65f7616ebb04c5bd82fbe [file] [log] [blame]
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));
}
}
}
}