com.urswolfer.gerrit.client.rest.http.GerritRestClient.java Source code

Java tutorial

Introduction

Here is the source code for com.urswolfer.gerrit.client.rest.http.GerritRestClient.java

Source

/*
 * Copyright 2013-2015 Urs Wolfer
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.urswolfer.gerrit.client.rest.http;

import com.google.common.base.Optional;
import com.google.common.base.Predicate;
import com.google.common.collect.Iterables;
import com.google.common.collect.Sets;
import com.google.common.io.CharStreams;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gson.*;
import com.squareup.okhttp.*;
import com.urswolfer.gerrit.client.rest.GerritAuthData;
import com.urswolfer.gerrit.client.rest.Version;
import com.urswolfer.gerrit.client.rest.gson.DateDeserializer;
import com.urswolfer.gerrit.client.rest.gson.DateSerializer;
import org.apache.http.*;
import org.apache.http.auth.*;
import org.apache.http.auth.Credentials;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.impl.auth.BasicScheme;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.client.LaxRedirectStrategy;
import org.apache.http.protocol.HttpContext;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.CookieStore;
import java.net.HttpCookie;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;

/**
 * This class provides basic http access to the rest interface of a gerrit instance.
 *
 * @author Urs Wolfer
 */
public class GerritRestClient {

    private static final Pattern GERRIT_AUTH_PATTERN = Pattern.compile(".*?xGerritAuth=\"(.+?)\"");
    private static final int CONNECTION_TIMEOUT_MS = 30000;
    private static final String PREEMPTIVE_AUTH = "preemptive-auth";
    private static final Gson GSON = initGson();

    private final GerritAuthData authData;
    private final HttpRequestExecutor httpRequestExecutor;
    private final List<HttpClientBuilderExtension> httpClientBuilderExtensions;

    private final CookieManager cookieManager;
    private final CookieStore cookieStore;
    private final LoginCache loginCache;

    public GerritRestClient(GerritAuthData authData, HttpRequestExecutor httpRequestExecutor,
            HttpClientBuilderExtension... httpClientBuilderExtensions) {
        this.authData = authData;
        this.httpRequestExecutor = httpRequestExecutor;
        this.httpClientBuilderExtensions = Arrays.asList(httpClientBuilderExtensions);

        cookieManager = new CookieManager();
        cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL);
        cookieStore = cookieManager.getCookieStore();
        loginCache = new LoginCache(authData, cookieStore);
    }

    public enum HttpVerb {
        GET, POST, DELETE, HEAD, PUT
    }

    public static final MediaType MEDIA_TYPE_JSON = MediaType.parse("application/json; charset=utf-8");

    public Gson getGson() {
        return GSON;
    }

    public JsonElement getRequest(String path) throws RestApiException {
        return request(path, null, HttpVerb.GET);
    }

    public JsonElement postRequest(String path, String requestBody) throws RestApiException {
        return request(path, requestBody, HttpVerb.POST);
    }

    public JsonElement putRequest(String path) throws RestApiException {
        return putRequest(path, null);
    }

    public JsonElement putRequest(String path, String requestBody) throws RestApiException {
        return request(path, requestBody, HttpVerb.PUT);
    }

    public JsonElement deleteRequest(String path) throws RestApiException {
        return request(path, null, HttpVerb.DELETE);
    }

    public JsonElement request(String path, String requestBody, HttpVerb verb) throws RestApiException {
        try {
            Response response = doRest(path, requestBody, verb);

            if (response.code() == 403 && loginCache.getGerritAuthOptional().isPresent()) {
                // handle expired sessions: try again with a fresh login
                loginCache.invalidate();
                response = doRest(path, requestBody, verb);
            }

            checkStatusCode(response);
            InputStream resp = response.body().byteStream();
            JsonElement ret = parseResponse(resp);
            if (ret.isJsonNull()) {
                throw new RestApiException("Unexpectedly empty response.");
            }
            return ret;
        } catch (IOException e) {
            throw new RestApiException("Request failed.", e);
        }
    }

    public Response doRest(String path, String requestBody, HttpVerb verb) throws IOException, RestApiException {
        OkHttpClient client = new OkHttpClient();

        Optional<String> gerritAuthOptional = updateGerritAuthWhenRequired(client);

        String uri = authData.getHost();
        // only use /a when http login is required (i.e. we haven't got a gerrit-auth cookie)
        // it would work in most cases also with /a, but it breaks with HTTP digest auth ("Forbidden" returned)
        if (authData.isLoginAndPasswordAvailable() && !gerritAuthOptional.isPresent()) {
            uri += "/a";
        }
        uri += path;

        Request.Builder builder = new Request.Builder().url(uri).addHeader("Accept", MEDIA_TYPE_JSON.toString());

        if (verb == HttpVerb.GET) {
            builder = builder.get();
        } else if (verb == HttpVerb.DELETE) {
            builder = builder.delete();
        } else {
            if (requestBody == null) {
                builder.method(verb.toString(), null);
            } else {
                builder.method(verb.toString(), RequestBody.create(MEDIA_TYPE_JSON, requestBody));
            }
        }

        if (gerritAuthOptional.isPresent()) {
            builder.addHeader("X-Gerrit-Auth", gerritAuthOptional.get());
        }

        return httpRequestExecutor.execute(client, builder);
    }

    private Optional<String> updateGerritAuthWhenRequired(OkHttpClient client) throws IOException {
        if (!loginCache.getHostSupportsGerritAuth()) {
            // We do not not need a cookie here since we are sending credentials as HTTP basic / digest header again.
            // In fact cookies could hurt: googlesource.com Gerrit instances block requests which send a magic cookie
            // named "gi" with a 400 HTTP status (as of 01/29/15).
            cookieStore.removeAll();
            return Optional.absent();
        }
        Optional<HttpCookie> gerritAccountCookie = findGerritAccountCookie();
        if (!gerritAccountCookie.isPresent() || gerritAccountCookie.get().hasExpired()) {
            return updateGerritAuth(client);
        }
        return loginCache.getGerritAuthOptional();
    }

    private Optional<String> updateGerritAuth(OkHttpClient client) throws IOException {
        Optional<String> gerritAuthOptional = tryGerritHttpAuth(client).or(tryGerritHttpFormAuth(client));
        loginCache.setGerritAuthOptional(gerritAuthOptional);
        return gerritAuthOptional;
    }

    /**
     * Handles LDAP auth (but not LDAP_HTTP) which uses a HTML form.
     */
    private Optional<String> tryGerritHttpFormAuth(OkHttpClient client) throws IOException {
        if (!authData.isLoginAndPasswordAvailable()) {
            return Optional.absent();
        }
        String loginUrl = authData.getHost() + "/login/";
        RequestBody formBody = new FormEncodingBuilder().add("username", authData.getLogin())
                .add("password", authData.getPassword()).build();

        Request.Builder builder = new Request.Builder().url(loginUrl).post(formBody);
        Response loginResponse = httpRequestExecutor.execute(client, builder);
        return extractGerritAuth(loginResponse);
    }

    /**
     * Try to authenticate against Gerrit instances with HTTP auth (not OAuth or something like that).
     * In case of success we get a GerritAccount cookie. In that case no more login credentials need to be sent as
     * long as we use the *same* HTTP client. Even requests against authenticated rest api (/a) will be processed
     * with the GerritAccount cookie.
     *
     * This is a workaround for "double" HTTP authentication (i.e. reverse proxy *and* Gerrit do HTTP authentication
     * for rest api (/a)).
     *
     * Following old notes from README about the issue:
     * If you have correctly set up a HTTP Password in Gerrit, but still have authentication issues, your Gerrit instance
     * might be behind a HTTP Reverse Proxy (like Nginx or Apache) with enabled HTTP Authentication. You can identify that if
     * you have to enter an username and password (browser password request) for opening the Gerrit web interface. Since this
     * plugin uses Gerrit REST API (with authentication enabled), you need to tell your system administrator that he should
     * disable HTTP Authentication for any request to <code>/a</code> path (e.g. https://git.example.com/a). For these requests
     * HTTP Authentication is done by Gerrit (double HTTP Authentication will not work). For more information see
     * [Gerrit documentation].
     * [Gerrit documentation]: https://gerrit-review.googlesource.com/Documentation/rest-api.html#authentication
     */
    private Optional<String> tryGerritHttpAuth(OkHttpClient client) throws IOException {
        String loginUrl = authData.getHost() + "/login/";
        Request.Builder builder = new Request.Builder().url(loginUrl).get();
        Response loginResponse = httpRequestExecutor.execute(client, builder);
        return extractGerritAuth(loginResponse);
    }

    private Optional<String> extractGerritAuth(Response loginResponse) throws IOException {
        if (loginResponse.code() != 401) {
            Optional<HttpCookie> gerritAccountCookie = findGerritAccountCookie();
            if (gerritAccountCookie.isPresent()) {
                // TODO
                /*Matcher matcher = GERRIT_AUTH_PATTERN.matcher(EntityUtils.toString(loginResponse.getEntity(), Consts.UTF_8));
                if (matcher.find()) {
                return Optional.of(matcher.group(1));
                }*/
            }
        }
        return Optional.absent();
    }

    private Optional<HttpCookie> findGerritAccountCookie() {
        List<HttpCookie> cookies = cookieStore.getCookies();
        return Iterables.tryFind(cookies, new Predicate<HttpCookie>() {
            @Override
            public boolean apply(HttpCookie cookie) {
                return cookie.getName().equals("GerritAccount");
            }
        });
    }

    private HttpClientBuilder getHttpClient(HttpContext httpContext) {
        HttpClientBuilder client = HttpClients.custom();

        client.useSystemProperties(); // see also: com.intellij.util.net.ssl.CertificateManager

        OkHttpClient c = new OkHttpClient();
        c.setFollowRedirects(true);
        // we need to get redirected result after login (which is done with POST) for extracting xGerritAuth
        client.setRedirectStrategy(new LaxRedirectStrategy());

        c.setCookieHandler(cookieManager);

        c.setConnectTimeout(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS);
        c.setReadTimeout(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS);
        c.setWriteTimeout(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS);

        CredentialsProvider credentialsProvider = getCredentialsProvider();
        client.setDefaultCredentialsProvider(credentialsProvider);

        if (authData.isLoginAndPasswordAvailable()) {
            credentialsProvider.setCredentials(AuthScope.ANY,
                    new UsernamePasswordCredentials(authData.getLogin(), authData.getPassword()));

            BasicScheme basicAuth = new BasicScheme();
            httpContext.setAttribute(PREEMPTIVE_AUTH, basicAuth);
            client.addInterceptorFirst(new PreemptiveAuthHttpRequestInterceptor(authData));
        }

        client.addInterceptorLast(new UserAgentHttpRequestInterceptor());

        for (HttpClientBuilderExtension httpClientBuilderExtension : httpClientBuilderExtensions) {
            client = httpClientBuilderExtension.extend(client, authData);
            credentialsProvider = httpClientBuilderExtension.extendCredentialProvider(client, credentialsProvider,
                    authData);
        }

        return client;
    }

    /**
     * With this impl, it only returns the same credentials once. Otherwise it's possible that a loop will occur.
     * When server returns status code 401, the HTTP client provides the same credentials forever.
     * Since we create a new HTTP client for every request, we can handle it this way.
     */
    private BasicCredentialsProvider getCredentialsProvider() {
        return new BasicCredentialsProvider() {
            private Set<AuthScope> authAlreadyTried = Sets.newHashSet();

            @Override
            public Credentials getCredentials(AuthScope authscope) {
                if (authAlreadyTried.contains(authscope)) {
                    return null;
                }
                authAlreadyTried.add(authscope);
                return super.getCredentials(authscope);
            }
        };
    }

    private JsonElement parseResponse(InputStream response) throws IOException {
        Reader reader = new InputStreamReader(response, Consts.UTF_8);
        reader.skip(5);
        try {
            return new JsonParser().parse(reader);
        } catch (JsonSyntaxException jse) {
            throw new IOException(String.format("Couldn't parse response: %n%s", CharStreams.toString(reader)),
                    jse);
        } finally {
            reader.close();
        }
    }

    private void checkStatusCode(Response response) throws HttpStatusException, IOException {
        int code = response.code();
        switch (code) {
        case HttpStatus.SC_OK:
        case HttpStatus.SC_CREATED:
        case HttpStatus.SC_ACCEPTED:
        case HttpStatus.SC_NO_CONTENT:
            return;
        case HttpStatus.SC_BAD_REQUEST:
        case HttpStatus.SC_UNAUTHORIZED:
        case HttpStatus.SC_PAYMENT_REQUIRED:
        case HttpStatus.SC_FORBIDDEN:
        default:
            String body = "<empty>";
            body = CharStreams.toString(response.body().charStream()).trim();
            String message = String.format("Request not successful. Message: %s. Status-Code: %s. Content: %s.",
                    response.message(), response.code(), body);
            throw new HttpStatusException(response.code(), response.message(), message);
        }
    }

    /**
     * With preemptive auth, it will send the basic authentication response even before the server gives an unauthorized
     * response in certain situations, thus reducing the overhead of making the connection again.
     *
     * Based on:
     * https://subversion.jfrog.org/jfrog/build-info/trunk/build-info-client/src/main/java/org/jfrog/build/client/PreemptiveHttpClient.java
     */
    private static class PreemptiveAuthHttpRequestInterceptor implements HttpRequestInterceptor {
        private GerritAuthData authData;

        public PreemptiveAuthHttpRequestInterceptor(GerritAuthData authData) {
            this.authData = authData;
        }

        public void process(final HttpRequest request, final HttpContext context)
                throws HttpException, IOException {
            AuthState authState = (AuthState) context.getAttribute(HttpClientContext.TARGET_AUTH_STATE);

            // if no auth scheme available yet, try to initialize it preemptively
            if (authState.getAuthScheme() == null) {
                AuthScheme authScheme = (AuthScheme) context.getAttribute(PREEMPTIVE_AUTH);
                UsernamePasswordCredentials creds = new UsernamePasswordCredentials(authData.getLogin(),
                        authData.getPassword());
                authState.update(authScheme, creds);
            }
        }
    }

    private static class UserAgentHttpRequestInterceptor implements HttpRequestInterceptor {

        public void process(final HttpRequest request, final HttpContext context)
                throws HttpException, IOException {
            Header existingUserAgent = request.getFirstHeader(HttpHeaders.USER_AGENT);
            String userAgent = String.format("gerrit-rest-java-client/%s", Version.get());
            userAgent += " using " + existingUserAgent.getValue();
            request.setHeader(HttpHeaders.USER_AGENT, userAgent);
        }
    }

    private static Gson initGson() {
        GsonBuilder builder = new GsonBuilder();
        builder.registerTypeAdapter(Date.class, new DateDeserializer());
        builder.registerTypeAdapter(Date.class, new DateSerializer());
        builder.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES);
        return builder.create();
    }
}