JavaNetHttpConnector.java

/*
 * Copyright (c) 2021, 2025 Oracle and/or its affiliates. All rights reserved.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License v. 2.0, which is available at
 * http://www.eclipse.org/legal/epl-2.0.
 *
 * This Source Code may also be made available under the following Secondary
 * Licenses when the conditions for such availability set forth in the
 * Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
 * version 2 with the GNU Classpath Exception, which is available at
 * https://www.gnu.org/software/classpath/license.html.
 *
 * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
 */

package org.glassfish.jersey.jnh.connector;

import jakarta.ws.rs.ProcessingException;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.core.Configuration;
import jakarta.ws.rs.core.MultivaluedMap;
import org.glassfish.jersey.client.ClientProperties;
import org.glassfish.jersey.client.ClientRequest;
import org.glassfish.jersey.client.ClientResponse;
import org.glassfish.jersey.client.innate.ClientProxy;
import org.glassfish.jersey.client.innate.Expect100ContinueUsage;
import org.glassfish.jersey.client.innate.http.SSLParamConfigurator;
import org.glassfish.jersey.client.spi.AsyncConnectorCallback;
import org.glassfish.jersey.client.spi.Connector;
import org.glassfish.jersey.internal.Version;
import org.glassfish.jersey.message.internal.OutboundMessageContext;
import org.glassfish.jersey.message.internal.Statuses;

import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Authenticator;
import java.net.CookieHandler;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.InetSocketAddress;
import java.net.PasswordAuthentication;
import java.net.ProxySelector;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Logger;

/**
 * Provides a Jersey client {@link Connector}, which internally uses Java's {@link HttpClient}.
 * The following properties are provided to Java's {@link HttpClient.Builder} during creation of the {@link HttpClient}:
 * <ul>
 *     <li>{@link ClientProperties#CONNECT_TIMEOUT}</li>
 *     <li>{@link ClientProperties#FOLLOW_REDIRECTS}</li>
 *     <li>{@link JavaNetHttpClientProperties#COOKIE_HANDLER}</li>
 *     <li>{@link JavaNetHttpClientProperties#SSL_PARAMETERS}</li>
 * </ul>
 *
 * @author Steffen Nie��ing
 */
public class JavaNetHttpConnector implements Connector {
    private static final Logger LOGGER = Logger.getLogger(JavaNetHttpConnector.class.getName());

    private final HttpClient httpClient;

    /**
     * Constructs a new {@link Connector} for a Jersey client instance using Java's {@link HttpClient}.
     *
     * @param client a Jersey client instance to get additional configuration properties from (e.g. {@link SSLContext})
     * @param configuration the configuration properties for this connector
     */
    public JavaNetHttpConnector(final Client client, final Configuration configuration) {
        final HttpClient.Builder httpClientBuilder = HttpClient.newBuilder();
        final HttpClient.Version version =
                getPropertyOrNull(configuration, JavaNetHttpClientProperties.HTTP_VERSION, HttpClient.Version.class);
        httpClientBuilder.version(version == null ? HttpClient.Version.HTTP_1_1 : version);
        SSLContext sslContext = client.getSslContext();
        if (sslContext != null) {
            httpClientBuilder.sslContext(sslContext);
        }
        final Integer connectTimeout =
                getPropertyOrNull(configuration, ClientProperties.CONNECT_TIMEOUT, Integer.class);
        if (connectTimeout != null) {
            httpClientBuilder.connectTimeout(Duration.ofMillis(connectTimeout));
        }
        final CookieHandler cookieHandler =
                getPropertyOrNull(configuration, JavaNetHttpClientProperties.COOKIE_HANDLER, CookieHandler.class);
        if (cookieHandler != null) {
            httpClientBuilder.cookieHandler(cookieHandler);
        }
        final Boolean disableCookies =
                getPropertyOrNull(configuration, JavaNetHttpClientProperties.DISABLE_COOKIES, Boolean.class);
        if (Boolean.TRUE.equals(disableCookies)) {
            httpClientBuilder.cookieHandler(new CookieManager(null, CookiePolicy.ACCEPT_NONE));
        }
        Boolean redirect = getPropertyOrNull(configuration, ClientProperties.FOLLOW_REDIRECTS, Boolean.class);
        if (redirect != null) {
            httpClientBuilder.followRedirects(redirect ? HttpClient.Redirect.ALWAYS : HttpClient.Redirect.NEVER);
        } else {
            httpClientBuilder.followRedirects(HttpClient.Redirect.NORMAL);
        }

        SSLParameters sslParameters =
                getPropertyOrNull(configuration, JavaNetHttpClientProperties.SSL_PARAMETERS, SSLParameters.class);
        sslParameters = new SniSslParameters(sslParameters).getSslParameters(client);
        if (sslParameters != null) {
            httpClientBuilder.sslParameters(sslParameters);
        }

        final Authenticator preemptiveAuthenticator =
                getPropertyOrNull(configuration,
                        JavaNetHttpClientProperties.PREEMPTIVE_BASIC_AUTHENTICATION, Authenticator.class);
        if (preemptiveAuthenticator != null) {
            httpClientBuilder.authenticator(preemptiveAuthenticator);
        }
        configureProxy(httpClientBuilder, configuration);
        this.httpClient = httpClientBuilder.build();
    }

    private static void configureProxy(HttpClient.Builder builder, final Configuration config) {

        final Optional<ClientProxy> proxy = ClientProxy.proxyFromConfiguration(config);
        proxy.ifPresent(clientProxy -> {
            final URI u = clientProxy.uri();
            final InetSocketAddress proxyAddress = new InetSocketAddress(u.getHost(),
                    u.getPort());
            if (clientProxy.userName() != null) {
                final Authenticator authenticator = new Authenticator() {
                    @Override
                    public PasswordAuthentication getPasswordAuthentication() {
                        return new PasswordAuthentication(clientProxy.userName(), clientProxy.password() == null
                                ? null : clientProxy.password().toCharArray());
                    }
                    @Override
                    protected RequestorType getRequestorType() {
                        return RequestorType.PROXY;
                    }
                };
                builder.authenticator(authenticator);
            }
            builder.proxy(ProxySelector.of(proxyAddress));
        });
    }

    /**
     * Implements a {@link org.glassfish.jersey.message.internal.OutboundMessageContext.StreamProvider}
     * for a {@link ByteArrayOutputStream}.
     */
    private static class ByteArrayOutputStreamProvider implements OutboundMessageContext.StreamProvider {
        private ByteArrayOutputStream byteArrayOutputStream;

        public ByteArrayOutputStream getByteArrayOutputStream() {
            return byteArrayOutputStream;
        }

        @Override
        public OutputStream getOutputStream(int contentLength) throws IOException {
            this.byteArrayOutputStream = contentLength > 0 ? new ByteArrayOutputStream(contentLength)
                    : new ByteArrayOutputStream();
            return this.byteArrayOutputStream;
        }
    }

    /**
     * Builds a request for the {@link HttpClient} from Jersey's {@link ClientRequest}.
     *
     * @param request the Jersey request to get request data from
     * @return the {@link HttpRequest} instance for the {@link HttpClient} request
     */
    private HttpRequest getHttpRequest(ClientRequest request) {
        final SSLParamConfigurator sniConfig = SSLParamConfigurator.builder()
                .uri(request.getUri())
                .configuration(request.getConfiguration())
                .build();

        final URI sniUri = sniConfig.isSNIRequired() ? sniConfig.toIPRequestUri() : request.getUri();

        HttpRequest.Builder builder = HttpRequest.newBuilder();
        builder.uri(sniUri);
        HttpRequest.BodyPublisher bodyPublisher = HttpRequest.BodyPublishers.noBody();
        if (request.hasEntity()) {
            try {
                ByteArrayOutputStreamProvider byteBufferStreamProvider = new ByteArrayOutputStreamProvider();
                request.setStreamProvider(byteBufferStreamProvider);
                request.writeEntity();
                bodyPublisher = HttpRequest.BodyPublishers.ofByteArray(
                        byteBufferStreamProvider.getByteArrayOutputStream().toByteArray()
                );
            } catch (IOException e) {
                throw new ProcessingException(LocalizationMessages.ERROR_INVALID_ENTITY(), e);
            }
        }
        builder.method(request.getMethod(), bodyPublisher);
        for (Map.Entry<String, List<String>> entry : request.getRequestHeaders().entrySet()) {
            String headerName = entry.getKey();
            for (String headerValue : entry.getValue()) {
                builder.header(headerName, headerValue);
            }
        }
        final Integer readTimeout = request.resolveProperty(ClientProperties.READ_TIMEOUT, Integer.class);
        if (readTimeout != null) {
            builder.timeout(Duration.ofMillis(readTimeout));
        }
        processExtensions(builder, request);
        return builder.build();
    }

     private static void processExtensions(HttpRequest.Builder builder, ClientRequest request) {
        builder.expectContinue(Expect100ContinueUsage.isAllowed(request, request.getMethod()));
    }

    /**
     * Retrieves a property from the configuration, if it was provided.
     *
     * @param configuration the {@link Configuration} to get the property information from
     * @param propertyKey the name of the property to retrieve
     * @param resultClass the type to which the property value should be case
     * @param <T> the generic type parameter of the result type
     * @return the requested property or {@code null}, if it was not provided or has the wrong type
     */
    @SuppressWarnings("unchecked")
    private <T> T getPropertyOrNull(final Configuration configuration, final String propertyKey, final Class<T> resultClass) {
        Object propertyObject = configuration.getProperty(propertyKey);
        if (propertyObject == null) {
            return null;
        }
        if (resultClass.isEnum() && propertyObject instanceof String) {
            return (T) Enum.valueOf(resultClass.asSubclass(Enum.class), (String) propertyObject);
        }
        if (!resultClass.isInstance(propertyObject)) {
            LOGGER.warning(LocalizationMessages.ERROR_INVALID_CLASS(propertyKey, resultClass.getName()));
            return null;
        }
        return (T) propertyObject;
    }

    /**
     * Translates a {@link HttpResponse} from the {@link HttpClient} to a Jersey {@link ClientResponse}.
     *
     * @param request the {@link ClientRequest} to get additional information (e.g. header values) from
     * @param response the {@link HttpClient} response object
     * @return the translated Jersey {@link ClientResponse} object
     */
    private ClientResponse buildClientResponse(ClientRequest request, HttpResponse<InputStream> response) {
        ClientResponse clientResponse = new ClientResponse(Statuses.from(response.statusCode()), request);
        MultivaluedMap<String, String> headers = clientResponse.getHeaders();
        for (Map.Entry<String, List<String>> entry : response.headers().map().entrySet()) {
            String headerName = entry.getKey();
            if (headers.get(headerName) != null) {
                headers.get(headerName).addAll(entry.getValue());
            } else {
                headers.put(headerName, entry.getValue());
            }
        }
        final InputStream body = response.body();
        try {
            clientResponse.setEntityStream(body.available() != 1 ? body : new FirstByteCachingStream(body));
        } catch (IOException ioe) {
            throw new ProcessingException(ioe);
        }
        return clientResponse;
    }

    /**
     * Returns the underlying {@link HttpClient} instance used by this connector.
     *
     * @return the Java {@link HttpClient} instance
     */
    public HttpClient getHttpClient() {
        return httpClient;
    }

    @Override
    public ClientResponse apply(ClientRequest request) {
        HttpRequest httpRequest = getHttpRequest(request);
        try {
            HttpResponse<InputStream> response = this.httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofInputStream());
            return buildClientResponse(request, response);
        } catch (IOException | InterruptedException e) {
            throw new ProcessingException(e);
        }
    }

    @Override
    public Future<?> apply(ClientRequest request, AsyncConnectorCallback callback) {
        HttpRequest httpRequest = getHttpRequest(request);
        CompletableFuture<ClientResponse> response = this.httpClient
                .sendAsync(httpRequest, HttpResponse.BodyHandlers.ofInputStream())
                .thenApply(httpResponse -> buildClientResponse(request, httpResponse));
        response.thenAccept(callback::response);
        return response;
    }

    @Override
    public String getName() {
        return "Java HttpClient Connector " + Version.getVersion();
    }

    @Override
    public void close() {

    }

    public CookieHandler getCookieHandler() {
        final Optional<CookieHandler> cookieHandler = httpClient.cookieHandler();
        if (cookieHandler.isPresent()) {
            return cookieHandler.get();
        }
        return null;
    }

    private static class SniSslParameters {
        private final SSLParameters sslParameters;

        private SniSslParameters(SSLParameters sslParameters) {
            this.sslParameters = sslParameters;
        }

        private SSLParameters getSslParameters(Client client) {
            SSLParamConfigurator sniConfig = SSLParamConfigurator.builder()
                    .configuration(client.getConfiguration())
                    .build();

            if (sniConfig.isSNIRequired()) {
                SSLParameters sslParameters = this.sslParameters;
                if (sslParameters == null) {
                    sslParameters = new SSLParameters();
                }
                sniConfig.setSNIServerName(sslParameters);
                return sslParameters;
            } else {
                return sslParameters;
            }
        }
    }

    /*
     * The JDK stream returns available() == 1 even when read() == -1
     * This class is to prevent it.
     * Otherwise, the MBR is not found for 204
     * See https://github.com/eclipse-ee4j/jersey/issues/5307
     */
    private static class FirstByteCachingStream extends InputStream {
        private final InputStream inner; //jdk.internal.net.http.ResponseSubscribers.HttpResponseInputStream
        private volatile int zero = -1; // int on zero index
        private final Lock lock = new ReentrantLock();

        private FirstByteCachingStream(InputStream inner) {
            this.inner = inner;
        }

        @Override
        public int read() throws IOException {
            lock.lock();
            try {
                final int r = zero != -1 ? zero : inner.read();
                zero = -1;
                return r;
            } finally {
                lock.unlock();
            }
        }

        @Override
        public int read(byte[] b, int off, int len) throws IOException {
            lock.lock();
            int r;
            try {
                if (zero != -1) {
                    b[off] = (byte) (zero & 0xFF);
                    r = inner.read(b, off + 1, len - 1);
                    r = (r == -1) ? 1 : r + 1;
                } else {
                    r = inner.read(b, off, len);
                }
                zero = -1;
            } finally {
                lock.unlock();
            }
            return r;

        }

        @Override
        public int available() throws IOException {
            lock.lock();
            try {
                if (zero != -1) {
                    return 1;
                }

                int available = inner.available();
                if (available != 1) {
                    return available;
                }

                zero = inner.read();
                if (zero == -1) {
                    available = 0;
                }
                return available;
            } finally {
                lock.unlock();
            }
        }

        @Override
        public void close() throws IOException {
            inner.close();
            lock.lock();
            zero = -1;
            lock.unlock();
        }

        @Override
        public boolean markSupported() {
            return inner.markSupported();
        }

        @Override
        public void mark(int readlimit) {
            inner.mark(readlimit);
        }

        @Override
        public void reset() throws IOException {
            inner.reset();
        }
    }

}