ApacheHttpBuilder.java

/**
 * Copyright (C) 2017 HttpBuilder-NG Project
 * 
 * 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 groovyx.net.http;

import groovy.lang.Closure;
import groovy.lang.DelegatesTo;
import groovyx.net.http.util.IoUtils;
import org.apache.http.*;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.HttpClient;
import org.apache.http.client.ResponseHandler;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.entity.GzipDecompressingEntity;
import org.apache.http.client.methods.*;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
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.impl.conn.BasicHttpClientConnectionManager;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.impl.io.EmptyInputStream;
import org.apache.http.message.BasicHeader;
import org.apache.http.protocol.HttpContext;
import org.apache.http.util.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLContext;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Proxy;
import java.net.Socket;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.function.Consumer;
import java.util.function.Function;

import static groovyx.net.http.HttpBuilder.ResponseHandlerFunction.HANDLER_FUNCTION;
import static groovyx.net.http.util.IoUtils.transfer;

/**
 * `HttpBuilder` implementation based on the https://hc.apache.org/httpcomponents-client-ga/[Apache HttpClient library].
 * 
 * Generally, this class should not be used directly, the preferred method of instantiation is via one of the two static `configure()` methods of this
 * class or using one of the `configure` methods of `HttpBuilder` with a factory function for this builder.
 */
public class ApacheHttpBuilder extends HttpBuilder {

    private static final Function<HttpObjectConfig, ? extends HttpBuilder> apacheFactory = ApacheHttpBuilder::new;
    private static final Logger log = LoggerFactory.getLogger(ApacheHttpBuilder.class);

    /**
     * Creates an `HttpBuilder` using the `ApacheHttpBuilder` factory instance configured with the provided configuration closure.
     * 
     * The configuration closure delegates to the {@link HttpObjectConfig} interface, which is an extension of the {@link HttpConfig} interface -
     * configuration properties from either may be applied to the global client configuration here. See the documentation for those interfaces for
     * configuration property details.
     * 
     * [source,groovy]
     * ----
     * def http = HttpBuilder.configure {
     * request.uri = 'http://localhost:10101'
     * }
     * ----
     *
     * @param closure the configuration closure (delegated to {@link HttpObjectConfig})
     * @return the configured `HttpBuilder`
     */
    public static HttpBuilder configure(@DelegatesTo(HttpObjectConfig.class) final Closure closure) {
        return configure(apacheFactory, closure);
    }

    /**
     * Creates an `HttpBuilder` using the `ApacheHttpBuilder` factory instance configured with the provided configuration function.
     * 
     * The configuration {@link Consumer} function accepts an instance of the {@link HttpObjectConfig} interface, which is an extension of the {@link HttpConfig}
     * interface - configuration properties from either may be applied to the global client configuration here. See the documentation for those interfaces for
     * configuration property details.
     * 
     * This configuration method is generally meant for use with standard Java.
     * 
     * [source,java]
     * ----
     * HttpBuilder.configure(new Consumer<HttpObjectConfig>() {
     * public void accept(HttpObjectConfig config) {
     * config.getRequest().setUri(format("http://localhost:%d", serverRule.getPort()));
     * }
     * });
     * ----
     * 
     * Or, using lambda expressions:
     * 
     * [source,java]
     * ----
     * HttpBuilder.configure(config -> {
     * config.getRequest().setUri(format("http://localhost:%d", serverRule.getPort()));
     * });
     * ----
     *
     * @param configuration the configuration function (accepting {@link HttpObjectConfig})
     * @return the configured `HttpBuilder`
     */
    public static HttpBuilder configure(final Consumer<HttpObjectConfig> configuration) {
        return configure(apacheFactory, configuration);
    }

    private class SocksHttp extends PlainConnectionSocketFactory {
        final Proxy proxy;

        public SocksHttp(final Proxy proxy) {
            this.proxy = proxy;
        }

        @Override
        public Socket createSocket(final HttpContext context) {
            return new Socket(proxy);
        }
    }

    private class SocksHttps extends SSLConnectionSocketFactory {
        final Proxy proxy;

        public SocksHttps(final Proxy proxy, final SSLContext sslContext, final HostnameVerifier verifier) {
            super(sslContext, verifier);
            this.proxy = proxy;
        }

        @Override
        public Socket createSocket(final HttpContext context) {
            return new Socket(proxy);
        }
    }

    private SSLContext sslContext(final HttpObjectConfig config) {
        try {
            return (config.getExecution().getSslContext() != null ?
                config.getExecution().getSslContext() :
                SSLContext.getDefault());
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }

    private Registry<ConnectionSocketFactory> registry(final HttpObjectConfig config) {
        final ProxyInfo proxyInfo = config.getExecution().getProxyInfo();

        final boolean isSocksProxied = (proxyInfo != null && proxyInfo.getProxy().type() == Proxy.Type.SOCKS);

        if (isSocksProxied) {
            return RegistryBuilder.<ConnectionSocketFactory>create()
                .register("http", new SocksHttp(proxyInfo.getProxy()))
                .register("https", new SocksHttps(proxyInfo.getProxy(), sslContext(config),
                    config.getExecution().getHostnameVerifier()))
                .build();
        } else {
            return RegistryBuilder.<ConnectionSocketFactory>create()
                .register("http", PlainConnectionSocketFactory.INSTANCE)
                .register("https", new SSLConnectionSocketFactory(sslContext(config), config.getExecution().getHostnameVerifier()))
                .build();
        }
    }

    private class ApacheFromServer implements FromServer {

        private final HttpResponse response;
        private final HttpEntity entity;
        private final List<Header<?>> headers;
        private final InputStream inputStream;
        private final URI uri;

        public ApacheFromServer(final URI originalUri, final HttpResponse response) {
            this.uri = originalUri;
            this.response = response;
            this.entity = response.getEntity();

            if (entity != null) {
                try {
                    this.inputStream = entity.getContent();
                } catch (IOException e) {
                    throw new RuntimeException("Could not get input stream from apache http client", e);
                }
            } else {
                this.inputStream = null;
            }

            this.headers = new ArrayList<>(response.getAllHeaders().length);
            for (org.apache.http.Header header : response.getAllHeaders()) {
                headers.add(Header.keyValue(header.getName(), header.getValue()));
            }

            addCookieStore(uri, headers);
        }

        public InputStream getInputStream() {
            return inputStream;
        }

        public boolean getHasBody() {
            return entity != null && !(inputStream instanceof EmptyInputStream);
        }

        public int getStatusCode() {
            return response.getStatusLine().getStatusCode();
        }

        public String getMessage() {
            return response.getStatusLine().getReasonPhrase();
        }

        public List<Header<?>> getHeaders() {
            return headers;
        }

        public URI getUri() {
            return uri;
        }

        public void finish() {
            EntityUtils.consumeQuietly(response.getEntity());
        }
    }

    public static class ApacheToServer implements ToServer, HttpEntity {

        private ChainedHttpConfig config;
        private byte[] bytes;

        public ApacheToServer(final ChainedHttpConfig config) {
            this.config = config;
        }

        public void toServer(final InputStream inputStream) {
            try {
                this.bytes = IoUtils.streamToBytes(inputStream);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }

        public boolean isRepeatable() {
            return true;
        }

        public boolean isChunked() {
            return false;
        }

        public long getContentLength() {
            return bytes.length;
        }

        public org.apache.http.Header getContentType() {
            return new BasicHeader("Content-Type", config.findContentType());
        }

        public org.apache.http.Header getContentEncoding() {
            return null;
        }

        public InputStream getContent() {
            return new ByteArrayInputStream(bytes);
        }

        public void writeTo(final OutputStream outputStream) {
            transfer(getContent(), outputStream, false);
        }

        public boolean isStreaming() {
            return true;
        }

        @SuppressWarnings("deprecation") //apache httpentity requires method
        public void consumeContent() throws IOException {
            bytes = null;
        }
    }

    private class Handler implements ResponseHandler<Object> {

        private final ChainedHttpConfig requestConfig;
        private final URI theUri;

        public Handler(final ChainedHttpConfig requestConfig) throws URISyntaxException {
            this.requestConfig = requestConfig;
            this.theUri = requestConfig.getChainedRequest().getUri().toURI();
        }

        public Object handleResponse(final HttpResponse response) {
            return HANDLER_FUNCTION.apply(requestConfig, new ApacheFromServer(theUri, response));
        }
    }

    final private CloseableHttpClient client;
    final private ChainedHttpConfig config;
    final private Executor executor;
    final private HttpObjectConfig.Client clientConfig;
    final private ProxyInfo proxyInfo;

    /**
     * Creates a new `HttpBuilder` based on the Apache HTTP client. While it is acceptable to create a builder with this method, it is generally
     * preferred to use one of the `static` `configure(...)` methods.
     *
     * @param config the configuration object
     */
    public ApacheHttpBuilder(final HttpObjectConfig config) {
        super(config);

        this.proxyInfo = config.getExecution().getProxyInfo();
        this.config = config.getChainedConfig();
        this.executor = config.getExecution().getExecutor();
        this.clientConfig = config.getClient();

        final HttpClientBuilder myBuilder = HttpClients.custom();

        final Registry<ConnectionSocketFactory> registry = registry(config);

        if (config.getExecution().getMaxThreads() > 1) {
            final PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(registry);
            cm.setMaxTotal(config.getExecution().getMaxThreads());
            cm.setDefaultMaxPerRoute(config.getExecution().getMaxThreads());
            myBuilder.setConnectionManager(cm);
        } else {
            final BasicHttpClientConnectionManager cm = new BasicHttpClientConnectionManager(registry);
            myBuilder.setConnectionManager(cm);
        }

        final SSLContext sslContext = config.getExecution().getSslContext();
        if (sslContext != null) {
            myBuilder.setSSLContext(sslContext);
            myBuilder.setSSLSocketFactory(new SSLConnectionSocketFactory(sslContext, config.getExecution().getHostnameVerifier()));
        }

        myBuilder.addInterceptorFirst((HttpResponseInterceptor) (response, context) -> {
            HttpEntity entity = response.getEntity();
            if (entity != null) {
                Header ceheader = entity.getContentEncoding();
                if (ceheader != null) {
                    HeaderElement[] codecs = ceheader.getElements();
                    for (HeaderElement codec : codecs) {
                        if (codec.getName().equalsIgnoreCase("gzip")) {
                            response.setEntity(new GzipDecompressingEntity(response.getEntity()));
                            return;
                        }
                    }
                }
            }
        });

        final Consumer<Object> clientCustomizer = clientConfig.getClientCustomizer();
        if (clientCustomizer != null) {
            clientCustomizer.accept(myBuilder);
        }

        this.client = myBuilder.build();
    }

    /**
     * Retrieves the internal client implementation as an {@link HttpClient} instance.
     *
     * @return the reference to the internal client implementation as an {@link HttpClient}
     */
    public Object getClientImplementation() {
        return client;
    }

    protected ChainedHttpConfig getObjectConfig() {
        return config;
    }

    public Executor getExecutor() {
        return executor;
    }

    public void close() {
        try {
            client.close();
        } catch (IOException ioe) {
            if (log.isWarnEnabled()) {
                log.warn("Error in closing http client", ioe);
            }
        }
    }

    private void basicAuth(final HttpClientContext c, final HttpConfig.Auth auth, final URI uri) {
        CredentialsProvider provider = new BasicCredentialsProvider();
        provider.setCredentials(AuthScope.ANY, //new AuthScope(uri.getHost(), port(uri)),
            new UsernamePasswordCredentials(auth.getUser(), auth.getPassword()));
        c.setCredentialsProvider(provider);
    }

    private void digestAuth(final HttpClientContext c, final HttpConfig.Auth auth, final URI uri) {
        basicAuth(c, auth, uri);
    }

    private HttpClientContext context(final ChainedHttpConfig requestConfig) throws URISyntaxException {
        final HttpClientContext c = HttpClientContext.create();
        final ChainedHttpConfig.ChainedRequest cr = requestConfig.getChainedRequest();
        final HttpConfig.Auth auth = cr.actualAuth();

        if (auth != null) {
            final URI uri = requestConfig.getRequest().getUri().toURI();
            if (auth.getAuthType() == HttpConfig.AuthType.BASIC) {
                basicAuth(c, auth, uri);
            } else if (auth.getAuthType() == HttpConfig.AuthType.DIGEST) {
                digestAuth(c, auth, uri);
            }
        }

        return c;
    }

    private <T extends HttpRequestBase> Object exec(final ChainedHttpConfig requestConfig, final Function<URI, T> constructor) {
        try {
            final ChainedHttpConfig.ChainedRequest cr = requestConfig.getChainedRequest();
            final URI theUri = cr.getUri().toURI();
            final T request = constructor.apply(theUri);

            if ((request instanceof HttpEntityEnclosingRequest) && cr.actualBody() != null) {
                final HttpEntity entity = entity(requestConfig);
                ((HttpEntityEnclosingRequest) request).setEntity(entity);
                request.setHeader(entity.getContentType());
            }

            addHeaders(cr, request);

            if (proxyInfo != null && proxyInfo.getProxy().type() == Proxy.Type.HTTP) {
                HttpHost proxy = new HttpHost(proxyInfo.getAddress(), proxyInfo.getPort(), proxyInfo.isSecure() ? "https" : "http");
                request.setConfig(RequestConfig.custom().setProxy(proxy).build());
            }

            return client.execute(request, new Handler(requestConfig), context(requestConfig));

        } catch (Exception e) {
            return handleException(requestConfig.getChainedResponse(), e);
        }
    }

    private HttpEntity entity(final ChainedHttpConfig config) {
        final ApacheToServer ats = new ApacheToServer(config);
        config.findEncoder().accept(config, ats);
        return ats;
    }

    @SuppressWarnings("Duplicates")
    private <T extends HttpUriRequest> void addHeaders(final ChainedHttpConfig.ChainedRequest cr, final T message) throws URISyntaxException {
        for (Map.Entry<String, CharSequence> entry : cr.actualHeaders(new LinkedHashMap<>()).entrySet()) {
            message.addHeader(entry.getKey(), entry.getValue() != null ? entry.getValue().toString() : null);
        }

        final String contentType = cr.actualContentType();
        if (contentType != null) {
            final Charset charset = cr.actualCharset();
            if (charset != null) {
                message.setHeader("Content-Type", contentType + "; charset=" + charset.toString().toLowerCase());
            } else {
                message.setHeader("Content-Type", contentType);
            }
        }

        for (Map.Entry<String, String> e : cookiesToAdd(clientConfig, cr).entrySet()) {
            message.addHeader(e.getKey(), e.getValue());
        }
    }

    protected Object doGet(final ChainedHttpConfig requestConfig) {
        return exec(requestConfig, HttpGet::new);
    }

    protected Object doHead(final ChainedHttpConfig requestConfig) {
        return exec(requestConfig, HttpHead::new);
    }

    protected Object doPost(final ChainedHttpConfig requestConfig) {
        return exec(requestConfig, HttpPost::new);
    }

    protected Object doPut(final ChainedHttpConfig requestConfig) {
        return exec(requestConfig, HttpPut::new);
    }

    protected Object doPatch(final ChainedHttpConfig requestConfig) {
        return exec(requestConfig, HttpPatch::new);
    }

    protected Object doDelete(final ChainedHttpConfig requestConfig) {
        return exec(requestConfig, HttpDelete::new);
    }

    @Override
    protected Object doOptions(final ChainedHttpConfig config) {
        return exec(config, HttpOptions::new);
    }

    @Override
    protected Object doTrace(final ChainedHttpConfig config) {
        return exec(config, HttpTrace::new);
    }
}