Issue #4881 - Java client connector

Provide a basic implementation of a client connector using java.net.http.HttpClient

Signed-off-by: Steffen Nießing <zuniquex@protonmail.com>
diff --git a/bom/pom.xml b/bom/pom.xml
index a54b744..d246628 100644
--- a/bom/pom.xml
+++ b/bom/pom.xml
@@ -75,6 +75,11 @@
             </dependency>
             <dependency>
                 <groupId>org.glassfish.jersey.connectors</groupId>
+                <artifactId>jersey-java-connector</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>org.glassfish.jersey.connectors</groupId>
                 <artifactId>jersey-jetty-connector</artifactId>
                 <version>${project.version}</version>
             </dependency>
diff --git a/connectors/java-connector/pom.xml b/connectors/java-connector/pom.xml
new file mode 100644
index 0000000..c2f1ef0
--- /dev/null
+++ b/connectors/java-connector/pom.xml
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+    Copyright (c) 2021 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
+
+-->
+
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>project</artifactId>
+        <groupId>org.glassfish.jersey.connectors</groupId>
+        <version>3.1.0-SNAPSHOT</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>jersey-java-connector</artifactId>
+    <packaging>jar</packaging>
+    <name>jersey-connectors-java</name>
+
+    <description>Jersey Client Transport via Java's HttpClient</description>
+
+    <properties>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.glassfish.jersey.test-framework.providers</groupId>
+            <artifactId>jersey-test-framework-provider-bundle</artifactId>
+            <version>${project.version}</version>
+            <type>pom</type>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.awaitility</groupId>
+            <artifactId>awaitility</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>com.sun.istack</groupId>
+                <artifactId>istack-commons-maven-plugin</artifactId>
+                <inherited>true</inherited>
+            </plugin>
+            <plugin>
+                <groupId>org.codehaus.mojo</groupId>
+                <artifactId>build-helper-maven-plugin</artifactId>
+                <inherited>true</inherited>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>
\ No newline at end of file
diff --git a/connectors/java-connector/src/main/java/org/glassfish/jersey/java/connector/JavaClientProperties.java b/connectors/java-connector/src/main/java/org/glassfish/jersey/java/connector/JavaClientProperties.java
new file mode 100644
index 0000000..cb8f60e
--- /dev/null
+++ b/connectors/java-connector/src/main/java/org/glassfish/jersey/java/connector/JavaClientProperties.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2021 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.java.connector;
+
+import org.glassfish.jersey.internal.util.PropertiesClass;
+
+import java.net.http.HttpClient;
+
+/**
+ * Provides configuration properties for a {@link JavaConnector}.
+ *
+ * @author Steffen Nießing
+ */
+@PropertiesClass
+public class JavaClientProperties {
+    /**
+     * Configuration of the {@link java.net.CookieHandler} that should be used by the {@link HttpClient}.
+     * If this option is not set, {@link HttpClient#cookieHandler()} will return an empty {@link java.util.Optional}
+     * and therefore no cookie handler will be used.
+     *
+     * A provided value to this option has to be of type {@link java.net.CookieHandler}.
+     */
+    public static final String COOKIE_HANDLER = "jersey.config.java.client.cookieHandler";
+
+    /**
+     * Configuration of SSL parameters used by the {@link HttpClient}.
+     * If this option is not set, then the {@link HttpClient} will use <it>implementation specific</it> default values.
+     *
+     * A provided value to this option has to be of type {@link javax.net.ssl.SSLParameters}.
+     */
+    public static final String SSL_PARAMETERS = "jersey.config.java.client.sslParameters";
+
+    /**
+     * Prevent this class from instantiation.
+     */
+    private JavaClientProperties() {}
+}
diff --git a/connectors/java-connector/src/main/java/org/glassfish/jersey/java/connector/JavaConnector.java b/connectors/java-connector/src/main/java/org/glassfish/jersey/java/connector/JavaConnector.java
new file mode 100644
index 0000000..9c104ab
--- /dev/null
+++ b/connectors/java-connector/src/main/java/org/glassfish/jersey/java/connector/JavaConnector.java
@@ -0,0 +1,234 @@
+/*
+ * Copyright (c) 2021 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.java.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.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.CookieHandler;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Future;
+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 JavaClientProperties#COOKIE_HANDLER}</li>
+ *     <li>{@link JavaClientProperties#SSL_PARAMETERS}</li>
+ * </ul>
+ *
+ * @author Steffen Nießing
+ */
+public class JavaConnector implements Connector {
+    private static final Logger LOGGER = Logger.getLogger(JavaConnector.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 JavaConnector(final Client client, final Configuration configuration) {
+        HttpClient.Builder httpClientBuilder = HttpClient.newBuilder();
+        httpClientBuilder.version(HttpClient.Version.HTTP_1_1);
+        SSLContext sslContext = client.getSslContext();
+        if (sslContext != null) {
+            httpClientBuilder.sslContext(sslContext);
+        }
+        Integer connectTimeout = getPropertyOrNull(configuration, ClientProperties.CONNECT_TIMEOUT, Integer.class);
+        if (connectTimeout != null) {
+            httpClientBuilder.connectTimeout(Duration.of(connectTimeout, ChronoUnit.MILLIS));
+        }
+        CookieHandler cookieHandler = getPropertyOrNull(configuration, JavaClientProperties.COOKIE_HANDLER, CookieHandler.class);
+        if (cookieHandler != null) {
+            httpClientBuilder.cookieHandler(cookieHandler);
+        }
+        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, JavaClientProperties.SSL_PARAMETERS, SSLParameters.class);
+        if (sslParameters != null) {
+            httpClientBuilder.sslParameters(sslParameters);
+        }
+        this.httpClient = httpClientBuilder.build();
+    }
+
+    /**
+     * 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 {
+            return this.byteArrayOutputStream = new ByteArrayOutputStream(contentLength);
+        }
+    }
+
+    /**
+     * 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) {
+        HttpRequest.Builder builder = HttpRequest.newBuilder();
+        builder.uri(request.getUri());
+        HttpRequest.BodyPublisher bodyPublisher = HttpRequest.BodyPublishers.noBody();
+        if (request.hasEntity()) {
+            try {
+                request.enableBuffering();
+                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);
+            }
+        }
+        return builder.build();
+    }
+
+    /**
+     * 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.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());
+            }
+        }
+        clientResponse.setEntityStream(response.body());
+        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() {
+
+    }
+}
diff --git a/connectors/java-connector/src/main/java/org/glassfish/jersey/java/connector/JavaConnectorProvider.java b/connectors/java-connector/src/main/java/org/glassfish/jersey/java/connector/JavaConnectorProvider.java
new file mode 100644
index 0000000..1fa38ab
--- /dev/null
+++ b/connectors/java-connector/src/main/java/org/glassfish/jersey/java/connector/JavaConnectorProvider.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2021 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.java.connector;
+
+import jakarta.ws.rs.client.Client;
+import jakarta.ws.rs.core.Configurable;
+import jakarta.ws.rs.core.Configuration;
+import org.glassfish.jersey.client.ClientProperties;
+import org.glassfish.jersey.client.Initializable;
+import org.glassfish.jersey.client.spi.Connector;
+import org.glassfish.jersey.client.spi.ConnectorProvider;
+
+import java.net.http.HttpClient;
+
+/**
+ * A provider class for a Jersey client {@link Connector} using Java's {@link HttpClient}.
+ * <p>
+ *     The following configuration properties are available:
+ *     <ul>
+ *         <li>{@link ClientProperties#CONNECT_TIMEOUT}</li>
+ *         <li>{@link ClientProperties#FOLLOW_REDIRECTS} (defaults to {@link java.net.http.HttpClient.Redirect#NORMAL} when unset)</li>
+ *         <li>{@link JavaClientProperties#COOKIE_HANDLER}</li>
+ *         <li>{@link JavaClientProperties#SSL_PARAMETERS}</li>
+ *     </ul>
+ * </p>
+ *
+ * @author Steffen Nießing
+ */
+public class JavaConnectorProvider implements ConnectorProvider {
+    @Override
+    public Connector getConnector(Client client, Configuration runtimeConfig) {
+        return new JavaConnector(client, runtimeConfig);
+    }
+
+    /**
+     * Retrieve the Java {@link HttpClient} used by the provided {@link JavaConnector}.
+     *
+     * @param component the component from which the {@link JavaConnector} should be retrieved
+     * @return a Java {@link HttpClient} instance
+     * @throws java.lang.IllegalArgumentException if a {@link JavaConnector} cannot be provided from the given {@code component}
+     */
+    public static HttpClient getHttpClient(Configurable<?> component) {
+        try {
+            final Initializable<?> initializable = (Initializable<?>) component;
+
+            Connector connector = initializable.getConfiguration().getConnector() != null
+                    ? initializable.getConfiguration().getConnector()
+                    : initializable.preInitialize().getConfiguration().getConnector();
+
+            if (connector instanceof JavaConnector) {
+                return ((JavaConnector) connector).getHttpClient();
+            } else {
+                throw new IllegalArgumentException(LocalizationMessages.EXPECTED_CONNECTOR_PROVIDER_NOT_USED());
+            }
+        } catch (ClassCastException classCastException) {
+            throw new IllegalArgumentException(
+                    LocalizationMessages.INVALID_CONFIGURABLE_COMPONENT_TYPE(component.getClass().getName()),
+                    classCastException
+            );
+        }
+    }
+}
diff --git a/connectors/java-connector/src/main/java/org/glassfish/jersey/java/connector/package-info.java b/connectors/java-connector/src/main/java/org/glassfish/jersey/java/connector/package-info.java
new file mode 100644
index 0000000..e50e0bb
--- /dev/null
+++ b/connectors/java-connector/src/main/java/org/glassfish/jersey/java/connector/package-info.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2021 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
+ */
+
+/**
+ * Jersey client {@link org.glassfish.jersey.client.spi.Connector connector} based on
+ * Java's {@link java.net.http.HttpClient}.
+ */
+package org.glassfish.jersey.java.connector;
\ No newline at end of file
diff --git a/connectors/java-connector/src/main/resources/org/glassfish/jersey/java/connector/localization.properties b/connectors/java-connector/src/main/resources/org/glassfish/jersey/java/connector/localization.properties
new file mode 100644
index 0000000..ca98e99
--- /dev/null
+++ b/connectors/java-connector/src/main/resources/org/glassfish/jersey/java/connector/localization.properties
@@ -0,0 +1,21 @@
+#
+# Copyright (c) 2021 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
+#
+
+error.body.publisher=Could not determine BodyPublisher for entity.
+error.invalid.class={0} is not an instance of {1}. Ignoring property.
+error.invalid.entity=Could not serialize entity.
+invalid.configurable.component.type=The supplied component "{0}" is not assignable from JerseyClient or JerseyWebTarget.
+expected.connector.provider.not.used=The supplied component is not configured to use a JavaConnectorProvider.
diff --git a/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/AbstractJavaConnectorTest.java b/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/AbstractJavaConnectorTest.java
new file mode 100644
index 0000000..7d3980d
--- /dev/null
+++ b/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/AbstractJavaConnectorTest.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2021 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.java.connector;
+
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.DefaultValue;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.container.AsyncResponse;
+import jakarta.ws.rs.container.Suspended;
+import jakarta.ws.rs.core.Application;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import org.glassfish.jersey.client.ClientConfig;
+import org.glassfish.jersey.logging.LoggingFeature;
+import org.glassfish.jersey.server.ResourceConfig;
+import org.glassfish.jersey.test.JerseyTest;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Logger;
+
+/**
+ * An abstract base class for tests of the {@link JavaConnector} providing common resources and utility methods.
+ */
+abstract class AbstractJavaConnectorTest extends JerseyTest {
+    private static final Logger LOGGER = Logger.getLogger(AbstractJavaConnectorTest.class.getName());
+    public static final String RESOURCE_PATH = "java-connector";
+
+    @Path(RESOURCE_PATH)
+    public static class JavaConnectorTestResource {
+        @GET
+        public String helloWorld() {
+            return "Hello World!";
+        }
+
+        @GET
+        @Path("redirect")
+        public Response redirectToHelloWorld() throws URISyntaxException {
+            return Response.seeOther(new URI(RESOURCE_PATH)).build();
+        }
+
+        @POST
+        @Path("echo")
+        @Produces(MediaType.TEXT_PLAIN)
+        @Consumes(MediaType.TEXT_PLAIN)
+        public String echo(String entity) {
+            return entity;
+        }
+
+        @POST
+        @Path("echo-byte-array")
+        @Produces(MediaType.APPLICATION_OCTET_STREAM)
+        @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+        public byte[] echoByteArray(byte[] byteArray) {
+            return byteArray;
+        }
+
+        @POST
+        @Path("async")
+        public void asyncPostWithTimeout(@QueryParam("timeout") @DefaultValue("10") Long timeoutSeconds,
+                                         @Suspended final AsyncResponse asyncResponse,
+                                         String message) {
+            asyncResponse.setTimeoutHandler(asyncResponse1 ->
+                    asyncResponse1.resume(Response.status(Response.Status.SERVICE_UNAVAILABLE).entity("Timeout").build()));
+            asyncResponse.setTimeout(timeoutSeconds, TimeUnit.SECONDS);
+            CompletableFuture.runAsync(() -> {
+                        try {
+                            Thread.sleep(3000);
+                        } catch (InterruptedException e) {
+                            Thread.currentThread().interrupt();
+                            throw new RuntimeException(e);
+                        }
+                    })
+                    .handleAsync((unused, throwable) -> throwable != null ? "INTERRUPTED" : message)
+                    .thenApplyAsync(asyncResponse::resume);
+        }
+    }
+
+    protected Response request(String path) {
+        return target().path(path).request().get();
+    }
+
+    protected Response requestWithEntity(String path, String method, Entity<?> entity) {
+        return target().path(path).request().method(method, entity);
+    }
+
+    protected Future<Response> requestAsync(String path) {
+        return target().path(path).request().async().get();
+    }
+
+    protected Future<Response> requestAsyncWithEntity(String path, String method, Entity<?> entity) {
+        return target().path(path).request().async().method(method, entity);
+    }
+
+    @Override
+    protected Application configure() {
+        return new ResourceConfig(JavaConnectorTestResource.class)
+                .register(new LoggingFeature(LOGGER, LoggingFeature.Verbosity.PAYLOAD_ANY));
+    }
+
+    @Override
+    protected void configureClient(ClientConfig config) {
+        config.register(new LoggingFeature(LOGGER, LoggingFeature.Verbosity.PAYLOAD_ANY));
+        config.connectorProvider(new JavaConnectorProvider());
+    }
+}
diff --git a/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/AsyncTest.java b/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/AsyncTest.java
new file mode 100644
index 0000000..dc08521
--- /dev/null
+++ b/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/AsyncTest.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2021 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.java.connector;
+
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Response;
+import org.junit.Test;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.awaitility.Awaitility.await;
+
+/**
+ * Tests asynchronous and interleaved requests.
+ */
+public class AsyncTest extends AbstractJavaConnectorTest {
+    /**
+     * Checks, that 3 interleaved requests all complete and return their associated responses.
+     * Additionally checks, that all requests complete in 3 times the running time on the server.
+     */
+    @Test
+    public void testAsyncRequestsWithoutTimeout() {
+        Future<Response> request1 = this.requestAsyncWithEntity("java-connector/async", "POST", Entity.text("request1"));
+        Future<Response> request2 = this.requestAsyncWithEntity("java-connector/async", "POST", Entity.text("request2"));
+        Future<Response> request3 = this.requestAsyncWithEntity("java-connector/async", "POST", Entity.text("request3"));
+
+        assertThatCode(() -> {
+            // wait 3 times the processing time and throw if not completed until then
+            await().atMost(3 * 3000, TimeUnit.MILLISECONDS)
+                    .until(() -> request1.isDone() && request2.isDone() && request3.isDone());
+            String response1 = request1.get().readEntity(String.class);
+            String response2 = request2.get().readEntity(String.class);
+            String response3 = request3.get().readEntity(String.class);
+            assertThat(response1).isEqualTo("request1");
+            assertThat(response2).isEqualTo("request2");
+            assertThat(response3).isEqualTo("request3");
+        }).doesNotThrowAnyException();
+    }
+
+    /**
+     * Checks, that a status {@link Response.Status#SERVICE_UNAVAILABLE} is thrown, if a request computes too long.
+     */
+    @Test
+    public void testAsyncRequestsWithTimeout() throws ExecutionException, InterruptedException {
+        try {
+            Response response = target().path("java-connector").path("async").queryParam("timeout", 1)
+                    .request().async().post(Entity.text("")).get();
+            assertThat(response.getStatus()).isEqualTo(Response.Status.SERVICE_UNAVAILABLE.getStatusCode());
+            assertThat(response.readEntity(String.class)).isEqualTo("Timeout");
+        } catch (InterruptedException | ExecutionException ex) {
+            throw new RuntimeException("Could not correctly get response", ex);
+        }
+    }
+}
diff --git a/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/BodyPublisherTest.java b/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/BodyPublisherTest.java
new file mode 100644
index 0000000..dbbbe82
--- /dev/null
+++ b/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/BodyPublisherTest.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2021 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.java.connector;
+
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+/**
+ * Checks, that request entities are correctly serialized and deserialized.
+ */
+public class BodyPublisherTest extends AbstractJavaConnectorTest {
+    /**
+     * Checks with a simple plain text entity.
+     */
+    @Test
+    public void testStringEntity() {
+        Response response = this.requestWithEntity("java-connector/echo", "POST", Entity.text("Echo"));
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThatCode(() -> {
+            assertThat(response.readEntity(String.class)).isEqualTo("Echo");
+        }).doesNotThrowAnyException();
+    }
+
+    /**
+     * Checks with an octet stream entity.
+     */
+    @Test
+    public void testByteArrayEntity() {
+        String test = "test-string";
+        Response response = this.requestWithEntity("java-connector/echo-byte-array", "POST",
+                Entity.entity(test.getBytes(StandardCharsets.UTF_8), MediaType.APPLICATION_OCTET_STREAM_TYPE));
+        assertThat(response.getStatus()).isEqualTo(200);
+        assertThatCode(() -> {
+            assertThat(response.readEntity(byte[].class))
+                    .satisfies(bytes -> assertThat(new String(bytes, StandardCharsets.UTF_8)).isEqualTo("test-string"));
+        }).doesNotThrowAnyException();
+    }
+}
diff --git a/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/OptionsMethodTest.java b/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/OptionsMethodTest.java
new file mode 100644
index 0000000..27ec125
--- /dev/null
+++ b/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/OptionsMethodTest.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2021 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.java.connector;
+
+import jakarta.ws.rs.core.Response;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Checks, that an {@code OPTIONS} request may be sent to a {@code GET} endpoint.
+ */
+public class OptionsMethodTest extends AbstractJavaConnectorTest {
+    /**
+     * Sends an {@code OPTIONS} request to the root {@code GET} endpoint and assumes a code 200.
+     */
+    @Test
+    public void testOptionsMethod() {
+        assertThat(this.requestWithEntity("java-connector", "OPTIONS", null).getStatus())
+                .isEqualTo(Response.Status.OK.getStatusCode());
+    }
+}
diff --git a/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/RedirectTest.java b/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/RedirectTest.java
new file mode 100644
index 0000000..1cd0c3d
--- /dev/null
+++ b/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/RedirectTest.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2021 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.java.connector;
+
+import jakarta.ws.rs.core.Response;
+import org.glassfish.jersey.client.ClientProperties;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Checks that the connector provider correctly handles redirects.
+ */
+public class RedirectTest extends AbstractJavaConnectorTest {
+    /**
+     * Checks, that without further configuration redirects are taken.
+     */
+    @Test
+    public void testRedirect() {
+        assertThat(this.request("java-connector/redirect").readEntity(String.class)).isEqualTo("Hello World!");
+    }
+
+    /**
+     * Checks, that no redirect happens, if the redirects are switched off.
+     */
+    @Test
+    public void testNotFollowRedirects() {
+        Response response = target().path("java-connector").path("redirect")
+                .property(ClientProperties.FOLLOW_REDIRECTS, false)
+                .request()
+                .get();
+        assertThat(response.getStatus()).isEqualTo(Response.Status.SEE_OTHER.getStatusCode());
+    }
+}
diff --git a/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/RetrieveHttpClientFromConnectorProviderTest.java b/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/RetrieveHttpClientFromConnectorProviderTest.java
new file mode 100644
index 0000000..aa831de
--- /dev/null
+++ b/connectors/java-connector/src/test/java/org/glassfish/jersey/java/connector/RetrieveHttpClientFromConnectorProviderTest.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2021 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.java.connector;
+
+import org.junit.Test;
+
+import java.net.http.HttpClient;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests access to the {@link HttpClient} instance provided from the {@link JavaConnectorProvider}.
+ */
+public class RetrieveHttpClientFromConnectorProviderTest extends AbstractJavaConnectorTest {
+    /**
+     * Checks, that the {@link jakarta.ws.rs.client.Client} and {@link jakarta.ws.rs.client.WebTarget} instances
+     * correctly return the internally used {@link HttpClient}.
+     */
+    @Test
+    public void testClientUsesJavaConnector() {
+        assertThat(JavaConnectorProvider.getHttpClient(client())).isInstanceOf(HttpClient.class);
+        assertThat(JavaConnectorProvider.getHttpClient(target())).isInstanceOf(HttpClient.class);
+        assertThat(JavaConnectorProvider.getHttpClient(client()))
+                .isEqualTo(JavaConnectorProvider.getHttpClient(target()));
+    }
+}
diff --git a/connectors/pom.xml b/connectors/pom.xml
index 332ec68..7592ef1 100644
--- a/connectors/pom.xml
+++ b/connectors/pom.xml
@@ -39,6 +39,7 @@
         <module>jdk-connector</module>
         <module>jetty-connector</module>
         <module>netty-connector</module>
+        <module>java-connector</module>
     </modules>
 
     <dependencies>
diff --git a/pom.xml b/pom.xml
index ba4e6dd..0d6fe4d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1948,6 +1948,19 @@
                 <version>6.9.6</version>
                 <scope>test</scope>
             </dependency>
+            <dependency>
+                <groupId>org.assertj</groupId>
+                <artifactId>assertj-core</artifactId>
+                <version>3.21.0</version>
+                <scope>test</scope>
+            </dependency>
+
+            <dependency>
+                <groupId>org.awaitility</groupId>
+                <artifactId>awaitility</artifactId>
+                <version>4.1.1</version>
+                <scope>test</scope>
+            </dependency>
 
             <dependency>
                 <groupId>org.hamcrest</groupId>