JavaHttpBuilder.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 groovyx.net.http.util.IoUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.*;
import java.nio.charset.Charset;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.concurrent.Executor;
import java.util.function.Consumer;
import java.util.zip.GZIPInputStream;
import java.util.zip.InflaterInputStream;

import static groovyx.net.http.HttpBuilder.ResponseHandlerFunction.HANDLER_FUNCTION;

/**
 * `HttpBuilder` implementation based on the {@link HttpURLConnection} class.
 *
 * Generally, this class should not be used directly, the preferred method of instantiation is via the
 * `groovyx.net.http.HttpBuilder.configure(java.util.function.Function)` or
 * `groovyx.net.http.HttpBuilder.configure(java.util.function.Function, groovy.lang.Closure)` methods.
 */
public class JavaHttpBuilder extends HttpBuilder {

    private static final Logger log = LoggerFactory.getLogger(JavaHttpBuilder.class);
    private static final Logger contentLog = LoggerFactory.getLogger("groovy.net.http.JavaHttpBuilder.content");
    private static final Logger headerLog = LoggerFactory.getLogger("groovy.net.http.JavaHttpBuilder.headers");

    protected class Action {

        private final HttpURLConnection connection;
        private final ChainedHttpConfig requestConfig;
        private final URI theUri;

        private boolean isProxied() {
            return proxyInfo != null && proxyInfo.getProxy().type() != Proxy.Type.DIRECT;
        }

        public Action(final Consumer<Object> clientCustomizer, final ChainedHttpConfig requestConfig, final String verb) throws IOException, URISyntaxException {
            this.requestConfig = requestConfig;

            final ChainedHttpConfig.ChainedRequest cr = requestConfig.getChainedRequest();
            theUri = cr.getUri().toURI();

            final URL url = theUri.toURL();
            connection = (HttpURLConnection) (isProxied() ? url.openConnection(proxyInfo.getProxy()) : url.openConnection());
            connection.setRequestMethod(verb);

            if (cr.actualBody() != null) {
                connection.setDoOutput(true);
            }

            if (clientCustomizer != null) {
                clientCustomizer.accept(connection);
            }
        }

        private void addHeaders() throws URISyntaxException {
            final ChainedHttpConfig.ChainedRequest cr = requestConfig.getChainedRequest();
            for (Map.Entry<String, CharSequence> entry : cr.actualHeaders(new LinkedHashMap<>()).entrySet()) {
                connection.addRequestProperty(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) {
                    connection.addRequestProperty("Content-Type", contentType + "; charset=" + charset.toString().toLowerCase());
                } else {
                    connection.addRequestProperty("Content-Type", contentType);
                }
            }

            connection.addRequestProperty("Accept-Encoding", "gzip, deflate");

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

            if (headerLog.isDebugEnabled()) {
                connection.getRequestProperties().forEach((name, values) -> headerLog.debug("Request-Header: {} -> {}", name, values));
            }
        }

        private PasswordAuthentication getAuthInfo() {
            final HttpConfig.Auth auth = requestConfig.getChainedRequest().actualAuth();
            if (auth == null) {
                return null;
            }

            if (auth.getAuthType() == HttpConfig.AuthType.BASIC || auth.getAuthType() == HttpConfig.AuthType.DIGEST) {
                return new PasswordAuthentication(auth.getUser(), auth.getPassword().toCharArray());
            } else {
                throw new UnsupportedOperationException("HttpURLConnection does not support " + auth.getAuthType() + " authentication");
            }
        }

        public Object execute() throws Exception {
            return ThreadLocalAuth.with(getAuthInfo(), () -> {
                if (sslContext != null && connection instanceof HttpsURLConnection) {
                    HttpsURLConnection https = (HttpsURLConnection) connection;

                    if (hostnameVerifier != null) {
                        https.setHostnameVerifier(hostnameVerifier);
                    }

                    https.setSSLSocketFactory(sslContext.getSocketFactory());
                }

                final ChainedHttpConfig.ChainedRequest cr = requestConfig.getChainedRequest();

                JavaToServer j2s = null;
                if (cr.actualBody() != null) {
                    j2s = new JavaToServer();
                    requestConfig.findEncoder().accept(requestConfig, j2s);
                }

                if (log.isDebugEnabled()) {
                    log.debug("Request-URI({}): {}", connection.getRequestMethod(), theUri);
                }

                addHeaders();

                connection.connect();

                if (j2s != null) {
                    if (contentLog.isDebugEnabled()) {
                        contentLog.debug("Request-Body({}): {}", requestConfig.getChainedRequest().actualContentType(), j2s.content());
                    }

                    j2s.transfer();

                }

                final JavaFromServer fromServer = new JavaFromServer(theUri);

                if (contentLog.isDebugEnabled()) {
                    contentLog.debug("Response-Body: {}", fromServer.content());
                }

                if (headerLog.isDebugEnabled()) {
                    fromServer.getHeaders().forEach(header -> headerLog.debug("Response-Header: {} -> {}", header.getKey(), header.getValue()));
                }

                return HANDLER_FUNCTION.apply(requestConfig, fromServer);
            });
        }

        protected class JavaToServer implements ToServer {

            private BufferedInputStream inputStream;

            public void toServer(final InputStream inputStream) {
                this.inputStream = inputStream instanceof BufferedInputStream ? (BufferedInputStream) inputStream : new BufferedInputStream(inputStream);
            }

            void transfer() throws IOException {
                IoUtils.transfer(inputStream, connection.getOutputStream(), true);
            }

            public String content() {
                try {
                    return IoUtils.copyAsString(inputStream);
                } catch (IOException ioe) {
                    if (log.isWarnEnabled()) {
                        log.warn("Unable to render request stream due to error (may not affect actual content)", ioe);
                    }
                } catch (IllegalStateException ise) {
                    if (log.isErrorEnabled()) {
                        log.error("Unable to reset request stream - actual content may be corrupted (consider disabling content logging)", ise);
                    }
                }
                return "<no-information>";
            }
        }

        protected class JavaFromServer implements FromServer {

            private final BufferedInputStream is;
            private final List<Header<?>> headers;
            private final URI uri;
            private final int statusCode;
            private final String message;

            public JavaFromServer(final URI originalUri) throws IOException {
                this.uri = originalUri;
                headers = populateHeaders();
                addCookieStore(uri, headers);
                statusCode = connection.getResponseCode();
                message = connection.getResponseMessage();
                BufferedInputStream bis = buffered(correctInputStream());
                is = (bis == null) ? null : handleEncoding(bis);
            }

            String content() {
                try {
                    return IoUtils.copyAsString(is);
                } catch (IOException ioe) {
                    log.warn("Unable to render response stream due to error (may not affect actual content)", ioe);
                } catch (IllegalStateException ise) {
                    log.error("Unable to reset response stream - actual content may be corrupted (consider disabling content logging)", ise);
                }
                return "<no-information>";
            }

            private BufferedInputStream buffered(final InputStream is) throws IOException {
                if (is == null) {
                    return null;
                }

                final BufferedInputStream bis = new BufferedInputStream(is);
                bis.mark(0);
                if (bis.read() == -1) {
                    return null;
                } else {
                    bis.reset();
                    return bis;
                }
            }

            private InputStream correctInputStream() throws IOException {
                if (getStatusCode() < 400) {
                    return connection.getInputStream();
                } else {
                    return connection.getErrorStream();
                }
            }

            private BufferedInputStream handleEncoding(final BufferedInputStream is) throws IOException {
                Header<?> encodingHeader = Header.find(headers, "Content-Encoding");
                if (encodingHeader != null) {
                    if (encodingHeader.getValue().equals("gzip")) {
                        return new BufferedInputStream(new GZIPInputStream(is));
                    } else if (encodingHeader.getValue().equals("deflate")) {
                        return new BufferedInputStream(new InflaterInputStream(is));
                    }
                }

                return is;
            }

            private String clean(final String str) {
                if (str == null) {
                    return null;
                }

                final String tmp = str.trim();
                return "".equals(tmp) ? null : tmp;
            }

            private List<Header<?>> populateHeaders() {
                final List<Header<?>> ret = new ArrayList<>();
                for (int i = 0; i < Integer.MAX_VALUE; ++i) {
                    final String key = clean(connection.getHeaderFieldKey(i));
                    final String value = clean(connection.getHeaderField(i));
                    if (key == null && value == null) {
                        break;
                    }

                    if (key != null && value != null) {
                        ret.add(Header.keyValue(key.trim(), value.trim()));
                    }
                }

                return Collections.unmodifiableList(ret);
            }

            public InputStream getInputStream() {
                return is;
            }

            public final int getStatusCode() {
                return statusCode;
            }

            public String getMessage() {
                return message;
            }

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

            public boolean getHasBody() {
                return is != null;
            }

            public URI getUri() {
                return uri;
            }

            public void finish() {
                //do nothing, should auto cleanup
            }
        }
    }

    protected static class ThreadLocalAuth extends Authenticator {
        private static final ThreadLocal<PasswordAuthentication> tlAuth = new ThreadLocal<PasswordAuthentication>();

        public PasswordAuthentication getPasswordAuthentication() {
            return tlAuth.get();
        }

        public static final <V> V with(final PasswordAuthentication pa, final Callable<V> callable) throws Exception {
            tlAuth.set(pa);
            try {
                return callable.call();
            } finally {
                tlAuth.set(null);
            }
        }
    }

    static {
        Authenticator.setDefault(new ThreadLocalAuth());
    }

    private final ChainedHttpConfig config;
    private final Executor executor;
    private final SSLContext sslContext;
    private final ProxyInfo proxyInfo;
    private final HostnameVerifier hostnameVerifier;
    private final HttpObjectConfig.Client clientConfig;

    protected JavaHttpBuilder(final HttpObjectConfig config) {
        super(config);
        this.config = config.getChainedConfig();
        this.executor = config.getExecution().getExecutor();
        this.clientConfig = config.getClient();
        this.hostnameVerifier = config.getExecution().getHostnameVerifier();
        this.sslContext = config.getExecution().getSslContext();
        this.proxyInfo = config.getExecution().getProxyInfo();
    }

    /**
     * The core Java client implementation does not support direct client access. This method will throw an {@link UnsupportedOperationException}.
     */
    @Override
    public Object getClientImplementation() {
        throw new UnsupportedOperationException("The core Java implementation does not support direct client access.");
    }

    protected ChainedHttpConfig getObjectConfig() {
        return config;
    }

    private Object createAndExecute(final ChainedHttpConfig config, final String verb) {
        try {
            Action action = new Action(clientConfig.getClientCustomizer(), config, verb);
            return action.execute();
        } catch (Exception e) {
            return handleException(config.getChainedResponse(), e);
        }
    }

    protected Object doGet(final ChainedHttpConfig requestConfig) {
        return createAndExecute(requestConfig, "GET");
    }

    protected Object doHead(final ChainedHttpConfig requestConfig) {
        return createAndExecute(requestConfig, "HEAD");
    }

    protected Object doPost(final ChainedHttpConfig requestConfig) {
        return createAndExecute(requestConfig, "POST");
    }

    protected Object doPut(final ChainedHttpConfig requestConfig) {
        return createAndExecute(requestConfig, "PUT");
    }

    protected Object doDelete(final ChainedHttpConfig requestConfig) {
        return createAndExecute(requestConfig, "DELETE");
    }

    protected Object doPatch(final ChainedHttpConfig requestConfig) {
        // The Java HttpURLConnection class only allows standard HTTP/1.1 verbs and will
        // throw a ProtocolException if the user tries to specified PATCH as the HTTP method.
        // See https://docs.oracle.com/javase/8/docs/api/java/net/HttpURLConnection.html#setRequestMethod-java.lang.String-
        throw new UnsupportedOperationException("java.net.HttpURLConnection does not support the PATCH method. Use the Apache or OkHttp providers instead.");
    }

    @Override
    protected Object doOptions(final ChainedHttpConfig config) {
        return createAndExecute(config, "OPTIONS");
    }

    @Override
    protected Object doTrace(final ChainedHttpConfig config) {
        return createAndExecute(config, "TRACE");
    }

    public Executor getExecutor() {
        return executor;
    }

    public void close() {
        //do nothing, not needed
    }
}