All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.expath.httpclient.impl.ApacheHttpConnection Maven / Gradle / Ivy

The newest version!
/****************************************************************************/
/*  File:       ApacheHttpConnection.java                                   */
/*  Author:     F. Georges - fgeorges.org                                   */
/*  Date:       2009-02-02                                                  */
/*  Tags:                                                                   */
/*      Copyright (c) 2009 Florent Georges (see end of file.)               */
/* ------------------------------------------------------------------------ */


package org.expath.httpclient.impl;

import java.io.*;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.util.concurrent.TimeUnit;
import java.util.zip.GZIPOutputStream;

import net.jcip.annotations.NotThreadSafe;
import org.expath.httpclient.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.HttpVersion;
import org.apache.http.auth.AuthScheme;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.Credentials;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.AuthCache;
import org.apache.http.client.CookieStore;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.entity.GzipCompressingEntity;
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.entity.ByteArrayEntity;
import org.apache.http.entity.ContentProducer;
import org.apache.http.entity.EntityTemplate;
import org.apache.http.impl.auth.BasicScheme;
import org.apache.http.impl.auth.DigestScheme;
import org.apache.http.impl.client.*;
import org.apache.http.impl.conn.*;
import org.apache.http.protocol.HTTP;
import org.apache.http.protocol.HttpContext;
import org.apache.http.ssl.SSLContexts;

import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;

/**
 * An implementation of an HTTP connection using Apachhe HTTP Client.
 *
 * @author Florent Georges
 */
@NotThreadSafe
public class ApacheHttpConnection
        implements HttpConnection
{
    public ApacheHttpConnection(URI uri)
    {
        myUri = uri;
        myRequest = null;
        myResponse = null;
        myVersion = DEFAULT_HTTP_VERSION;
    }

    @Override
    public void connect(final HttpRequestBody body, final HttpCredentials cred)
            throws HttpClientException
    {
        if ( myRequest == null ) {
            throw new HttpClientException(HttpClientError.HC001, "setRequestMethod has not been called before");
        }

        myRequest.setProtocolVersion(myVersion);

        try {
            // make a new client
            if(myClient == null) {
                myClient = makeClient();
            }

            if(myResponse != null) {
                // close any previous response
                myResponse.close();
            }

            // set the credentials (if any)
            final HttpClientContext clientContext = setCredentials(cred);
            // set the request entity body (if any)
            setRequestEntity(body);
            // log the request headers?
            if ( LOG.isDebugEnabled() ) {
                LOG.debug("METHOD: " + myRequest.getMethod());
                Header[] headers = myRequest.getAllHeaders();
                LoggerHelper.logHeaders(LOG, "REQ HEADERS", headers);
                LoggerHelper.logCookies(LOG, "COOKIES", COOKIES.getCookies());
            }
            // send the request
            myResponse = myClient.execute(myRequest, clientContext);

            // TODO: Handle 'Connection' headers (for instance "Connection: close")
            // See for instance http://www.jmarshall.com/easy/http/.
            // ...

            // log the response headers?
            if ( LOG.isDebugEnabled() ) {
                Header[] headers = myResponse.getAllHeaders();
                LoggerHelper.logHeaders(LOG, "RESP HEADERS", headers);
                LoggerHelper.logCookies(LOG, "COOKIES", COOKIES.getCookies());
            }
        }
        catch ( IOException ex ) {
            final String message = getMessage(ex);
            throw new HttpClientException(HttpClientError.HC001, "Error executing the HTTP method: " + message != null ? message : "", ex);
        } finally {
            state = State.POST_CONNECT;
        }
    }

    /**
     * Retrieves a message from the Throwable
     * or its cause (recursively).
     *
     * @param throwable A thrown exception
     *
     * @return The first message, or null if there are no messages
     *     at all.
     */
    private String getMessage(final Throwable throwable) {
        if(throwable.getMessage() != null) {
            return throwable.getMessage();
        }

        final Throwable cause = throwable.getCause();
        if(cause == null || cause == throwable) {
            return null;
        }

        return getMessage(cause);
    }

    @Override
    public void disconnect() throws HttpClientException {
        try {
            if(myResponse != null) {
                myResponse.close();
                myResponse = null;
            }

            myClient.close();
            myClient = null;
        } catch (final IOException ex) {
            final String message = getMessage(ex);
            throw new HttpClientException(HttpClientError.HC001, message, ex);
        }
    }

    @Override
    public void setHttpVersion(final String ver)
            throws HttpClientException
    {
        if ( state != State.INITIAL ) {
            String msg = "Internal error, HTTP version cannot been "
                    + "set after connect() has been called.";
            throw new HttpClientException(HttpClientError.HC005, msg);
        }
        if ( HttpConstants.HTTP_1_0.equals(ver) ) {
            myVersion = HttpVersion.HTTP_1_0;
        }
        else if ( HttpConstants.HTTP_1_1.equals(ver) ) {
            myVersion = HttpVersion.HTTP_1_1;
        }
        else {
            throw new HttpClientException(HttpClientError.HC005, "Internal error, unknown HTTP version: '" + ver + "'");
        }
    }

    public void setRequestHeaders(HeaderSet headers)
            throws HttpClientException
    {
        if ( myRequest == null ) {
            throw new HttpClientException(HttpClientError.HC001, "setRequestMethod has not been called before");
        }
        myRequest.setHeaders(headers.toArray());
    }

    public void setRequestMethod(String method, boolean with_content)
            throws HttpClientException
    {
        if ( LOG.isDebugEnabled() ) {
            LOG.debug("Request method: " + method + " (" + with_content + ")");
        }
        String uri = myUri.toString();
        String m = method.toUpperCase();
        if ( "DELETE".equals(m) ) {
            myRequest = new HttpDelete(uri);
        }
        else if ( "GET".equals(m) ) {
            myRequest = new HttpGet(uri);
        }
        else if ( "HEAD".equals(m) ) {
            myRequest = new HttpHead(uri);
        }
        else if ( "OPTIONS".equals(m) ) {
            myRequest = new HttpOptions(uri);
        }
        else if ( "POST".equals(m) ) {
            myRequest = new HttpPost(uri);
        }
        else if ( "PUT".equals(m) ) {
            myRequest = new HttpPut(uri);
        }
        else if ( "TRACE".equals(m) ) {
            myRequest = new HttpTrace(uri);
        }
        else if ( ! checkMethodName(method) ) {
            throw new HttpClientException(HttpClientError.HC005, "Invalid HTTP method name [" + method + "]");
        }
        else if ( with_content ) {
            myRequest = new AnyEntityMethod(m, uri);
        }
        else {
            myRequest = new AnyEmptyMethod(m, uri);
        }
    }

    public void setFollowRedirect(boolean follow)
    {
        myFollowRedirect = follow;
    }

    public void setTimeout(int seconds)
    {
        myTimeout = seconds;
    }

    @Override
    public void setGzip(final boolean gzip) {
        myGzip = gzip;
    }

    @Override
    public void setChunked(final boolean chunked) {
        myChunked = chunked;
    }

    @Override
    public void setPreemptiveAuthentication(final boolean preemptiveAuthentication) {
        myPreemptiveAuthentication = preemptiveAuthentication;
    }

    /**
     * Check the method name does match the HTTP/1.1 production rules.
     *
     *     Method         = "OPTIONS"                ; Section 9.2
     *                    | "GET"                    ; Section 9.3
     *                    | "HEAD"                   ; Section 9.4
     *                    | "POST"                   ; Section 9.5
     *                    | "PUT"                    ; Section 9.6
     *                    | "DELETE"                 ; Section 9.7
     *                    | "TRACE"                  ; Section 9.8
     *                    | "CONNECT"                ; Section 9.9
     *                    | extension-method
     *
     *     extension-method = token
     *
     *     token          = 1*<any CHAR except CTLs or separators>
     *
     *     CHAR           = <any US-ASCII character (octets 0 - 127)>
     *
     *     CTL            = <any US-ASCII control character
     *                      (octets 0 - 31) and DEL (127)>
     *
     *     separators     = "(" | ")" | "<" | ">" | "@"
     *                    | "," | ";" | ":" | "\" | <">
     *                    | "/" | "[" | "]" | "?" | "="
     *                    | "{" | "}" | SP | HT
     */
    private boolean checkMethodName(String method)
    {
        for ( char c : method.toCharArray() ) {
            if ( c > 127 || ! METHOD_CHARS[c] ) {
                return false;
            }
        }
        return true;
    }

    private static final boolean[] METHOD_CHARS = new boolean[128];
    static {
        // SP = 32, HT = 9, so any char between 33 and 126 incl., minus
        // explicitly excluded chars...
        String excl = "()<>@,;:\\\"/[]?={}";
        for ( char c = 0; c < 128; ++ c ) {
            if ( c < 33 || c == 127 ) {
                METHOD_CHARS[c] = false;
            }
            else if ( excl.indexOf(c) == -1 ) {
                METHOD_CHARS[c] = true;
            }
            else {
                METHOD_CHARS[c] = false;
            }
        }
    }

    public int getResponseStatus()
    {
        return myResponse.getStatusLine().getStatusCode();
    }

    public String getResponseMessage()
    {
        return myResponse.getStatusLine().getReasonPhrase();
    }

    public HeaderSet getResponseHeaders()
            throws HttpClientException
    {
        return new HeaderSet(myResponse.getAllHeaders());
    }

    /**
     * TODO: How to use Apache HTTP Client facilities for response content
     * handling, instead of parsing this stream myself?
     */
    public InputStream getResponseStream()
            throws HttpClientException
    {
        try {
            HttpEntity entity = myResponse.getEntity();
            return entity == null ? null : entity.getContent();
        }
        catch ( IOException ex ) {
            throw new HttpClientException(HttpClientError.HC001, "Error getting the HTTP response stream", ex);
        }
    }

    /**
     * Make a new Apache HTTP client, in order to serve this request.
     */
    private CloseableHttpClient makeClient() {

        final HttpClientBuilder clientBuilder = HttpClientBuilder.create()
            .setConnectionManager(POOLING_CONNECTION_MANAGER)
            .setConnectionManagerShared(true);

        // use the default JVM proxy settings (http.proxyHost, etc.)
        clientBuilder.setRoutePlanner(new SystemDefaultRoutePlanner(null));

        // do follow redirections?
        if(myFollowRedirect) {
            clientBuilder.setRedirectStrategy(LaxRedirectStrategy.INSTANCE);
        } else {
            clientBuilder.disableRedirectHandling();
        }

        // the shared cookie store
        clientBuilder.setDefaultCookieStore(COOKIES);

        // set the timeout if any
        final RequestConfig.Builder requestConfigBuilder = RequestConfig.custom();
        if(myTimeout != null) {
            // See http://blog.jayway.com/2009/03/17/configuring-timeout-with-apache-httpclient-40/
            requestConfigBuilder
                    .setConnectTimeout(myTimeout * 1000)
                    .setSocketTimeout(myTimeout * 1000);
        }
        clientBuilder.setDefaultRequestConfig(requestConfigBuilder.build());

        final CloseableHttpClient client = clientBuilder.build();
        return client;
    }

    /**
     * Set the credentials on the client, based on the {@link HttpCredentials} object.
     */
    private HttpClientContext setCredentials(HttpCredentials cred)
            throws HttpClientException {
        final HttpClientContext clientContext = HttpClientContext.create();

        if (cred == null) {
            return clientContext;
        }

        final URI uri = myRequest.getURI();
        final String scheme = uri.getScheme();
        int port = uri.getPort();
        if (port == -1) {
            if ("http".equals(scheme)) {
                port = 80;
            } else if ("https".equals(scheme)) {
                port = 443;
            } else {
                throw new HttpClientException(HttpClientError.HC001, "Unknown scheme: " + uri);
            }
        }
        final String host = uri.getHost();

        final HttpHost targetHost = new HttpHost(host, port, scheme);

        final String user = cred.getUser();
        final String pwd = cred.getPwd();

        if (LOG.isDebugEnabled()) {
            LOG.debug("Set credentials for " + targetHost.getHostName() + ":" + targetHost.getPort()
                    + " - " + user + " - ***");
        }

        final Credentials c = new UsernamePasswordCredentials(user, pwd);
        final AuthScope scope = new AuthScope(targetHost);

        if (clientContext.getCredentialsProvider() == null) {
            clientContext.setCredentialsProvider(new BasicCredentialsProvider());
        } else {
            clientContext.getCredentialsProvider().clear();
        }

        clientContext.getCredentialsProvider().setCredentials(scope, c);

        // force preemptive authentication?
        // see - https://hc.apache.org/httpcomponents-client-ga/tutorial/html/authentication.html#d5e717
        if (myPreemptiveAuthentication) {

            // is there already an auth cache?
            if (clientContext.getAuthCache() == null) {
                // no, so create one
                final AuthCache authCache = new BasicAuthCache();
                clientContext.setAuthCache(authCache);
            }

            // set the auth cache scheme
            final AuthScheme authScheme;
            if (cred.getMethod().equals("DIGEST")) {
                authScheme = new DigestScheme();
            } else {
                authScheme = new BasicScheme();
            }

            clientContext.getAuthCache().put(targetHost, authScheme);
        }

        return clientContext;
    }

    /**
     * Configure the request to get its entity body from the {@link HttpRequestBody}.
     */
    private void setRequestEntity(HttpRequestBody body)
            throws HttpClientException
    {
        if ( body == null ) {
            return;
        }
        // make the entity from a new producer
        final HttpEntity entity;
        if ( myVersion == HttpVersion.HTTP_1_1 ) {

            final HttpEntity template;
            if(myChunked) {
                // Take advantage of HTTP 1.1 chunked encoding to stream the
                // payload directly to the request.
                final ContentProducer producer = new RequestBodyProducer(body);
                final EntityTemplate entityTemplate = new EntityTemplate(producer);
                entityTemplate.setContentType(body.getContentType());
                entityTemplate.setChunked(true);
                template = entityTemplate;

            } else {
                /*
                    NOTE: for some reason even if you set EntityTemplate#setChunked(false),
                    Apache insists on chunking anyway... So, instead we manually buffer here
                    to foce non-chunked transfer encoding.
                 */
                try (final ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
                    body.serialize(buffer);
                    template = new ByteArrayEntity(buffer.toByteArray());
                } catch (final IOException e) {
                    throw new HttpClientException(HttpClientError.HC001, e.getMessage(), e);
                }
            }

            if(myGzip) {
                entity = new GzipCompressingEntity(template);
            } else {
                entity = template;
            }
        }
        else {
            // With HTTP 1.0, chunked encoding is not supported, so first
            // serialize into memory and use the resulting byte array as the
            // entity payload.
            try (final ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
                if(myGzip) {
                    try (final GZIPOutputStream gzip = new GZIPOutputStream(buffer)) {
                        body.serialize(gzip);
                    }
                    myRequest.setHeader(HTTP.CONTENT_ENCODING, "gzip");
                } else {
                    body.serialize(buffer);
                }
                entity = new ByteArrayEntity(buffer.toByteArray());
            } catch (final IOException e) {
                throw new HttpClientException(HttpClientError.HC001, e.getMessage(), e);
            }
        }

        // cast the request
        HttpEntityEnclosingRequestBase req = null;
        if ( ! (myRequest instanceof HttpEntityEnclosingRequestBase) ) {
            String msg = "Body not allowed on a " + myRequest.getMethod() + " request";
            throw new HttpClientException(HttpClientError.HC001, msg);
        }
        else {
            req = (HttpEntityEnclosingRequestBase) myRequest;
        }
        // set the entity on the request
        req.setEntity(entity);
    }

    private static PoolingHttpClientConnectionManager setupConnectionPool() {
        final SSLContext sslContext = SSLContexts.
                createSystemDefault();

        final SSLConnectionSocketFactory sslConnectionSocketFactory = new SSLSocketFactoryWithSNI(sslContext);

        final Registry socketFactoryRegistry = RegistryBuilder.create()
                .register("https", sslConnectionSocketFactory)
                .register("http", PlainConnectionSocketFactory.INSTANCE)
                .build();

        final PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager(socketFactoryRegistry, null, null, null, 15, TimeUnit.MINUTES);     //TODO(AR) TTL is currently 15 minutes, make configurable?
        poolingHttpClientConnectionManager.setMaxTotal(40); //TODO(AR) total pooled connections is 40, make configurable?
        poolingHttpClientConnectionManager.setDefaultMaxPerRoute(2);    //TODO(AR) max default connections per route is 2, make configurable?
        return poolingHttpClientConnectionManager;
    }

    /**
     * Implements SNI (Server Name Identification) for SSL.
     * Fixes https://github.com/fgeorges/expath-http-client-java/issues/5.
     */
    private static class SSLSocketFactoryWithSNI extends SSLConnectionSocketFactory {
        public SSLSocketFactoryWithSNI(final SSLContext sslContext) {
            super(sslContext);
        }

        @Override
        public Socket connectSocket(final int connectTimeout, final Socket socket, final HttpHost host,
                final InetSocketAddress remoteAddress, final InetSocketAddress localAddress, final HttpContext context)
                throws IOException {
            if (socket instanceof SSLSocket) {
                try {
                    final Class socketClazz = socket.getClass();
                    final Method m = socketClazz.getDeclaredMethod("setHost", String.class);
                    m.setAccessible(true);
                    m.invoke(socket, host.getHostName());
                } catch (final NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
                    LOG.warn("Problem whilst setting SNI: " + e.getMessage(), e);
                }
            }

            return super.connectSocket(connectTimeout, socket, host, remoteAddress, localAddress, context);
        }
    }

    private enum State {
        INITIAL,
        POST_CONNECT
    }

    private static final PoolingHttpClientConnectionManager POOLING_CONNECTION_MANAGER = setupConnectionPool();

    private State state = State.INITIAL;

    /** The target URI. */
    private URI myUri;
    /** The Apache client. */
    private CloseableHttpClient myClient;
    /** The Apache request. */
    private HttpRequestBase myRequest;
    /** The Apache response. */
    private CloseableHttpResponse myResponse;
    /** The HTTP protocol version. */
    private HttpVersion myVersion;
    /** Follow HTTP redirect? */
    private boolean myFollowRedirect = true;
    /** The timeout to use, in seconds, or null for default. */
    private Integer myTimeout = null;
    /** whether we should use gzip transfer encoding */
    private boolean myGzip = false;
    private boolean myChunked = true;
    private boolean myPreemptiveAuthentication = false;

    /**
     * The shared cookie store.
     *
     * TODO: Make it possible to serialize the cookies to disk?
     */
    private static final CookieStore COOKIES = new BasicCookieStore();
    /** The logger. */
    private static final Logger LOG = LoggerFactory.getLogger(ApacheHttpConnection.class);

    /**
     * The HTTP version (1.0 or 1.1) to use by default.
     * 
     * Configurable by the system property {@code org.expath.hc.http.version}.
     * By default, use HTTP 1.1.  Can be set on a per-request basis, by setting
     * the {@code http:request/@http} attribute.
     */
    private static HttpVersion DEFAULT_HTTP_VERSION = HttpVersion.HTTP_1_1;
    static {
        String ver = System.getProperty("org.expath.hc.http.version");
        if ( ver != null ) {
            ver = ver.trim();
            if ( "1.0".equals(ver) ) {
                DEFAULT_HTTP_VERSION = HttpVersion.HTTP_1_0;
            }
            else if ( "1.1".equals(ver) ) {
                DEFAULT_HTTP_VERSION = HttpVersion.HTTP_1_1;
            }
            else {
                String msg = "Wrong HTTP version: " + ver + " (check org.expath.hc.http.version)";
                throw new RuntimeException(msg);
            }
        }
    }

    /**
     * A request entity producer, generating content from an {@link HttpRequestBody}.
     */
    private static class RequestBodyProducer
            implements ContentProducer
    {
        public RequestBodyProducer(HttpRequestBody body)
        {
            myBody = body;
        }

        public void writeTo(OutputStream out)
                throws IOException
        {
            try {
                myBody.serialize(out);
            }
            catch ( HttpClientException ex ) {
                throw new IOException("Error serializing the body content", ex);
            }
        }

        private HttpRequestBody myBody;
    }
}


/* ------------------------------------------------------------------------ */
/*  DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS COMMENT.               */
/*                                                                          */
/*  The contents of this file are subject to the Mozilla Public License     */
/*  Version 1.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.mozilla.org/MPL/.                                            */
/*                                                                          */
/*  Software distributed under the License is distributed on an "AS IS"     */
/*  basis, WITHOUT WARRANTY OF ANY KIND, either express or implied.  See    */
/*  the License for the specific language governing rights and limitations  */
/*  under the License.                                                      */
/*                                                                          */
/*  The Original Code is: all this file.                                    */
/*                                                                          */
/*  The Initial Developer of the Original Code is Florent Georges.          */
/*                                                                          */
/*  Contributor(s): none.                                                   */
/* ------------------------------------------------------------------------ */




© 2015 - 2024 Weber Informatics LLC | Privacy Policy