diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 5756e093c49..7e89540de50 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -44,6 +44,8 @@ stages:
module: hapi-fhir-cli/hapi-fhir-cli-api
- name: hapi_fhir_client
module: hapi-fhir-client
+ - name: hapi_fhir_client_apache_http5
+ module: hapi-fhir-client-apache-http5
- name: hapi_fhir_client_okhttp
module: hapi-fhir-client-okhttp
- name: hapi_fhir_converter
diff --git a/hapi-fhir-bom/pom.xml b/hapi-fhir-bom/pom.xml
index 08f577f3dff..9269f6860ef 100644
--- a/hapi-fhir-bom/pom.xml
+++ b/hapi-fhir-bom/pom.xml
@@ -44,6 +44,11 @@
hapi-fhir-client
${project.version}
+
+ ${project.groupId}
+ hapi-fhir-client-apache-http5
+ ${project.version}
+
${project.groupId}
hapi-fhir-client-okhttp
diff --git a/hapi-fhir-client-apache-http5/pom.xml b/hapi-fhir-client-apache-http5/pom.xml
new file mode 100644
index 00000000000..00c91cb4f2b
--- /dev/null
+++ b/hapi-fhir-client-apache-http5/pom.xml
@@ -0,0 +1,60 @@
+
+ 4.0.0
+
+ ca.uhn.hapi.fhir
+ hapi-deployable-pom
+ 7.7.7-SNAPSHOT
+
+ ../hapi-deployable-pom/pom.xml
+
+
+ hapi-fhir-client-apache-http5
+ jar
+
+ HAPI FHIR - Client Framework using Apache HttpClient 5
+
+
+
+ ca.uhn.hapi.fhir
+ hapi-fhir-base
+ ${project.version}
+
+
+ commons-logging
+ commons-logging
+
+
+ org.apache.httpcomponents
+ httpclient
+
+
+ org.apache.httpcomponents
+ httpcore
+
+
+
+
+
+
+ org.apache.httpcomponents.client5
+ httpclient5
+
+
+ commons-logging
+ commons-logging
+
+
+
+
+ org.apache.httpcomponents.core5
+ httpcore5
+
+
+
+ ca.uhn.hapi.fhir
+ hapi-fhir-client
+ ${project.version}
+
+
+
diff --git a/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5GZipContentInterceptor.java b/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5GZipContentInterceptor.java
new file mode 100644
index 00000000000..8a5e8ed9a4f
--- /dev/null
+++ b/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5GZipContentInterceptor.java
@@ -0,0 +1,74 @@
+/*
+ * #%L
+ * HAPI FHIR - Client Framework
+ * %%
+ * Copyright (C) 2014 - 2024 Smile CDR, Inc.
+ * %%
+ * 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.
+ * #L%
+ */
+package ca.uhn.fhir.rest.client.apache;
+
+import ca.uhn.fhir.rest.api.Constants;
+import ca.uhn.fhir.rest.client.api.IClientInterceptor;
+import ca.uhn.fhir.rest.client.api.IHttpRequest;
+import ca.uhn.fhir.rest.client.api.IHttpResponse;
+import org.apache.hc.client5.http.classic.methods.HttpUriRequest;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.zip.GZIPOutputStream;
+
+/**
+ * Client interceptor which GZip compresses outgoing (POST/PUT) contents being uploaded
+ * from the client to the server. This can improve performance by reducing network
+ * load time.
+ */
+public class ApacheHttp5GZipContentInterceptor implements IClientInterceptor {
+ private static final org.slf4j.Logger ourLog =
+ org.slf4j.LoggerFactory.getLogger(ApacheHttp5GZipContentInterceptor.class);
+
+ @Override
+ public void interceptRequest(IHttpRequest theRequestInterface) {
+ HttpUriRequest theRequest = ((ApacheHttp5Request) theRequestInterface).getApacheRequest();
+ if (theRequest != null) {
+ Header[] encodingHeaders = theRequest.getHeaders(Constants.HEADER_CONTENT_ENCODING);
+ if (encodingHeaders == null || encodingHeaders.length == 0) {
+
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ GZIPOutputStream gos;
+ try {
+ gos = new GZIPOutputStream(bos);
+ theRequest.getEntity().writeTo(gos);
+ gos.finish();
+ } catch (IOException e) {
+ ourLog.warn("Failed to GZip outgoing content", e);
+ return;
+ }
+
+ byte[] byteArray = bos.toByteArray();
+ ByteArrayEntity newEntity = new ByteArrayEntity(byteArray, ContentType.APPLICATION_OCTET_STREAM);
+ theRequest.setEntity(newEntity);
+ theRequest.addHeader(Constants.HEADER_CONTENT_ENCODING, "gzip");
+ }
+ }
+ }
+
+ @Override
+ public void interceptResponse(IHttpResponse theResponse) throws IOException {
+ // nothing
+ }
+}
diff --git a/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5ModifiedStringResponse.java b/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5ModifiedStringResponse.java
new file mode 100644
index 00000000000..cb11c80ca12
--- /dev/null
+++ b/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5ModifiedStringResponse.java
@@ -0,0 +1,135 @@
+/*
+ * #%L
+ * HAPI FHIR - Client Framework
+ * %%
+ * Copyright (C) 2014 - 2024 Smile CDR, Inc.
+ * %%
+ * 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.
+ * #L%
+ */
+package ca.uhn.fhir.rest.client.apache;
+
+import ca.uhn.fhir.i18n.Msg;
+import ca.uhn.fhir.rest.client.api.IHttpResponse;
+import ca.uhn.fhir.rest.client.impl.BaseHttpResponse;
+import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
+import ca.uhn.fhir.util.StopWatch;
+import org.apache.commons.io.IOUtils;
+
+import java.io.ByteArrayInputStream;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Process a modified copy of an existing {@link IHttpResponse} with a String containing new content.
+ *
+ * Meant to be used with custom interceptors that need to hijack an existing IHttpResponse with new content.
+ */
+public class ApacheHttp5ModifiedStringResponse extends BaseHttpResponse implements IHttpResponse {
+ private static final org.slf4j.Logger ourLog =
+ org.slf4j.LoggerFactory.getLogger(ApacheHttp5ModifiedStringResponse.class);
+ private boolean myEntityBuffered = false;
+ private final String myNewContent;
+ private final IHttpResponse myOrigHttpResponse;
+ private byte[] myEntityBytes = null;
+
+ public ApacheHttp5ModifiedStringResponse(
+ IHttpResponse theOrigHttpResponse, String theNewContent, StopWatch theResponseStopWatch) {
+ super(theResponseStopWatch);
+ myOrigHttpResponse = theOrigHttpResponse;
+ myNewContent = theNewContent;
+ }
+
+ @Override
+ public void bufferEntity() throws IOException {
+ if (myEntityBuffered) {
+ return;
+ }
+ try (InputStream respEntity = readEntity()) {
+ if (respEntity != null) {
+ try {
+ myEntityBytes = IOUtils.toByteArray(respEntity);
+ } catch (IllegalStateException exception) {
+ throw new InternalErrorException(Msg.code(2580) + exception);
+ }
+ myEntityBuffered = true;
+ }
+ }
+ }
+
+ @Override
+ public void close() {
+ if (myOrigHttpResponse instanceof Closeable) {
+ try {
+ ((Closeable) myOrigHttpResponse).close();
+ } catch (IOException exception) {
+ ourLog.debug("Failed to close response", exception);
+ }
+ }
+ }
+
+ @Override
+ public Reader createReader() {
+ return new InputStreamReader(readEntity(), StandardCharsets.UTF_8);
+ }
+
+ @Override
+ public Map> getAllHeaders() {
+ return myOrigHttpResponse.getAllHeaders();
+ }
+
+ @Override
+ public List getHeaders(String theName) {
+ return myOrigHttpResponse.getHeaders(theName);
+ }
+
+ @Override
+ public String getMimeType() {
+ return myOrigHttpResponse.getMimeType();
+ }
+
+ @Override
+ public StopWatch getRequestStopWatch() {
+ return myOrigHttpResponse.getRequestStopWatch();
+ }
+
+ @Override
+ public Object getResponse() {
+ return null;
+ }
+
+ @Override
+ public int getStatus() {
+ return myOrigHttpResponse.getStatus();
+ }
+
+ @Override
+ public String getStatusInfo() {
+ return myOrigHttpResponse.getStatusInfo();
+ }
+
+ @Override
+ public InputStream readEntity() {
+ if (myEntityBuffered) {
+ return new ByteArrayInputStream(myEntityBytes);
+ } else {
+ return new ByteArrayInputStream(myNewContent.getBytes());
+ }
+ }
+}
diff --git a/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5Request.java b/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5Request.java
new file mode 100644
index 00000000000..a70be6c4c21
--- /dev/null
+++ b/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5Request.java
@@ -0,0 +1,131 @@
+/*
+ * #%L
+ * HAPI FHIR - Client Framework
+ * %%
+ * Copyright (C) 2014 - 2024 Smile CDR, Inc.
+ * %%
+ * 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.
+ * #L%
+ */
+package ca.uhn.fhir.rest.client.apache;
+
+import ca.uhn.fhir.rest.client.api.BaseHttpRequest;
+import ca.uhn.fhir.rest.client.api.IHttpRequest;
+import ca.uhn.fhir.rest.client.api.IHttpResponse;
+import ca.uhn.fhir.util.StopWatch;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.Validate;
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.client5.http.classic.methods.HttpUriRequest;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpEntity;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.Charset;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A Http Request based on Apache. This is an adapter around the class
+ * {@link HttpUriRequest HttpRequestBase }
+ *
+ * @author Peter Van Houte | peter.vanhoute@agfa.com | Agfa Healthcare
+ */
+public class ApacheHttp5Request extends BaseHttpRequest implements IHttpRequest {
+
+ private HttpClient myClient;
+ private HttpUriRequest myRequest;
+
+ public ApacheHttp5Request(HttpClient theClient, HttpUriRequest theApacheRequest) {
+ this.myClient = theClient;
+ this.myRequest = theApacheRequest;
+ }
+
+ @Override
+ public void addHeader(String theName, String theValue) {
+ myRequest.addHeader(theName, theValue);
+ }
+
+ @Override
+ public IHttpResponse execute() throws IOException {
+ StopWatch responseStopWatch = new StopWatch();
+ return myClient.execute(myRequest, httpResponse -> new ApacheHttp5Response(httpResponse, responseStopWatch));
+ }
+
+ @Override
+ public Map> getAllHeaders() {
+ Map> result = new HashMap<>();
+ for (Header header : myRequest.getHeaders()) {
+ if (!result.containsKey(header.getName())) {
+ result.put(header.getName(), new LinkedList<>());
+ }
+ result.get(header.getName()).add(header.getValue());
+ }
+ return Collections.unmodifiableMap(result);
+ }
+
+ /**
+ * Get the ApacheRequest
+ *
+ * @return the ApacheRequest
+ */
+ public HttpUriRequest getApacheRequest() {
+ return myRequest;
+ }
+
+ @Override
+ public String getHttpVerbName() {
+ return myRequest.getMethod();
+ }
+
+ @Override
+ public void removeHeaders(String theHeaderName) {
+ Validate.notBlank(theHeaderName, "theHeaderName must not be null or blank");
+ myRequest.removeHeaders(theHeaderName);
+ }
+
+ @Override
+ public String getRequestBodyFromStream() throws IOException {
+ if (myRequest != null) {
+ HttpEntity entity = myRequest.getEntity();
+ if (entity.isRepeatable()) {
+ final Header contentTypeHeader = myRequest.getFirstHeader("Content-Type");
+ Charset charset = contentTypeHeader == null
+ ? null
+ : ContentType.parse(contentTypeHeader.getValue()).getCharset();
+ return IOUtils.toString(entity.getContent(), charset);
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public String getUri() {
+ return myRequest.getRequestUri().toString();
+ }
+
+ @Override
+ public void setUri(String theUrl) {
+ myRequest.setUri(URI.create(theUrl));
+ }
+
+ @Override
+ public String toString() {
+ return myRequest.toString();
+ }
+}
diff --git a/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5ResourceEntity.java b/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5ResourceEntity.java
new file mode 100644
index 00000000000..a0e97607654
--- /dev/null
+++ b/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5ResourceEntity.java
@@ -0,0 +1,42 @@
+/*-
+ * #%L
+ * HAPI FHIR - Client Framework
+ * %%
+ * Copyright (C) 2014 - 2024 Smile CDR, Inc.
+ * %%
+ * 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.
+ * #L%
+ */
+package ca.uhn.fhir.rest.client.apache;
+
+import ca.uhn.fhir.context.FhirContext;
+import ca.uhn.fhir.rest.api.Constants;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.hl7.fhir.instance.model.api.IBaseResource;
+
+import java.nio.charset.UnsupportedCharsetException;
+
+/**
+ * Apache HttpClient request content entity where the body is a FHIR resource, that will
+ * be encoded as JSON by default
+ */
+public class ApacheHttp5ResourceEntity extends StringEntity {
+
+ public ApacheHttp5ResourceEntity(FhirContext theContext, IBaseResource theResource)
+ throws UnsupportedCharsetException {
+ super(
+ theContext.newJsonParser().encodeResourceToString(theResource),
+ ContentType.parse(Constants.CT_FHIR_JSON_NEW));
+ }
+}
diff --git a/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5Response.java b/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5Response.java
new file mode 100644
index 00000000000..a9b7aefabf1
--- /dev/null
+++ b/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5Response.java
@@ -0,0 +1,177 @@
+/*
+ * #%L
+ * HAPI FHIR - Client Framework
+ * %%
+ * Copyright (C) 2014 - 2024 Smile CDR, Inc.
+ * %%
+ * 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.
+ * #L%
+ */
+package ca.uhn.fhir.rest.client.apache;
+
+import ca.uhn.fhir.i18n.Msg;
+import ca.uhn.fhir.rest.api.Constants;
+import ca.uhn.fhir.rest.client.api.IHttpResponse;
+import ca.uhn.fhir.rest.client.impl.BaseHttpResponse;
+import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
+import ca.uhn.fhir.util.StopWatch;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpResponse;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.StringReader;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A Http Response based on Apache. This is an adapter around the class
+ * {@link org.apache.hc.core5.http.ClassicHttpResponse HttpResponse}
+ *
+ * @author Peter Van Houte | peter.vanhoute@agfa.com | Agfa Healthcare
+ */
+public class ApacheHttp5Response extends BaseHttpResponse implements IHttpResponse {
+
+ private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(ApacheHttp5Response.class);
+
+ private boolean myEntityBuffered = false;
+ private byte[] myEntityBytes;
+ private final ClassicHttpResponse myResponse;
+
+ public ApacheHttp5Response(ClassicHttpResponse theResponse, StopWatch theResponseStopWatch) {
+ super(theResponseStopWatch);
+ this.myResponse = theResponse;
+ }
+
+ @Override
+ public void bufferEntity() throws IOException {
+ if (myEntityBuffered) {
+ return;
+ }
+ try (InputStream respEntity = readEntity()) {
+ if (respEntity != null) {
+ this.myEntityBuffered = true;
+ try {
+ this.myEntityBytes = IOUtils.toByteArray(respEntity);
+ } catch (IllegalStateException e) {
+ throw new InternalErrorException(Msg.code(2581) + e);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void close() {
+ if (myResponse instanceof CloseableHttpResponse) {
+ try {
+ myResponse.close();
+ } catch (IOException e) {
+ ourLog.debug("Failed to close response", e);
+ }
+ }
+ }
+
+ @Override
+ public Reader createReader() throws IOException {
+ HttpEntity entity = myResponse.getEntity();
+ if (entity == null) {
+ return new StringReader("");
+ }
+ Charset charset = null;
+ String contentType = entity.getContentType();
+ if (StringUtils.isNotBlank(contentType)) {
+ ContentType ct = ContentType.parse(contentType);
+ charset = ct.getCharset();
+ }
+ if (charset == null) {
+ if (Constants.STATUS_HTTP_204_NO_CONTENT != myResponse.getCode()) {
+ ourLog.debug("Response did not specify a charset, defaulting to utf-8");
+ }
+ charset = StandardCharsets.UTF_8;
+ }
+
+ return new InputStreamReader(readEntity(), charset);
+ }
+
+ @Override
+ public Map> getAllHeaders() {
+ Map> headers = new HashMap<>();
+ Header[] allHeaders = myResponse.getHeaders();
+ if (allHeaders != null) {
+ for (Header next : allHeaders) {
+ String name = next.getName().toLowerCase();
+ List list = headers.computeIfAbsent(name, k -> new ArrayList<>());
+ list.add(next.getValue());
+ }
+ }
+ return headers;
+ }
+
+ @Override
+ public List getHeaders(String theName) {
+ Header[] headers = myResponse.getHeaders(theName);
+ if (headers == null) {
+ headers = new Header[0];
+ }
+ List retVal = new ArrayList<>();
+ for (Header next : headers) {
+ retVal.add(next.getValue());
+ }
+ return retVal;
+ }
+
+ @Override
+ public String getMimeType() {
+ ContentType ct = ContentType.parse(myResponse.getEntity().getContentType());
+ return ct != null ? ct.getMimeType() : null;
+ }
+
+ @Override
+ public HttpResponse getResponse() {
+ return myResponse;
+ }
+
+ @Override
+ public int getStatus() {
+ return myResponse.getCode();
+ }
+
+ @Override
+ public String getStatusInfo() {
+ return myResponse.getReasonPhrase();
+ }
+
+ @Override
+ public InputStream readEntity() throws IOException {
+ if (this.myEntityBuffered) {
+ return new ByteArrayInputStream(myEntityBytes);
+ } else if (myResponse.getEntity() != null) {
+ return myResponse.getEntity().getContent();
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5RestfulClient.java b/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5RestfulClient.java
new file mode 100644
index 00000000000..d7ecd6f761c
--- /dev/null
+++ b/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5RestfulClient.java
@@ -0,0 +1,142 @@
+/*
+ * #%L
+ * HAPI FHIR - Client Framework
+ * %%
+ * Copyright (C) 2014 - 2024 Smile CDR, Inc.
+ * %%
+ * 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.
+ * #L%
+ */
+package ca.uhn.fhir.rest.client.apache;
+
+import ca.uhn.fhir.rest.api.RequestTypeEnum;
+import ca.uhn.fhir.rest.client.api.Header;
+import ca.uhn.fhir.rest.client.api.IHttpClient;
+import ca.uhn.fhir.rest.client.api.IHttpRequest;
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.client5.http.classic.methods.*;
+import org.apache.hc.client5.http.entity.UrlEncodedFormEntity;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.NameValuePair;
+import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
+import org.apache.hc.core5.http.message.BasicNameValuePair;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import static org.apache.hc.core5.http.ContentType.APPLICATION_OCTET_STREAM;
+
+/**
+ * A Http Client based on Apache. This is an adapter around the class
+ * {@link org.apache.hc.client5.http.classic HttpClient}
+ *
+ * @author Peter Van Houte | peter.vanhoute@agfa.com | Agfa Healthcare
+ */
+public class ApacheHttp5RestfulClient extends BaseHttpClient implements IHttpClient {
+
+ private final HttpClient myClient;
+ private final HttpHost host;
+
+ public ApacheHttp5RestfulClient(
+ HttpClient theClient,
+ StringBuilder theUrl,
+ Map> theIfNoneExistParams,
+ String theIfNoneExistString,
+ RequestTypeEnum theRequestType,
+ List theHeaders) {
+ super(theUrl, theIfNoneExistParams, theIfNoneExistString, theRequestType, theHeaders);
+ this.myClient = theClient;
+ this.host = new HttpHost(theUrl.toString());
+ }
+
+ private HttpUriRequestBase constructRequestBase(HttpEntity theEntity) {
+ String url = myUrl.toString();
+ switch (myRequestType) {
+ case DELETE:
+ return new HttpDelete(url);
+ case PATCH:
+ HttpPatch httpPatch = new HttpPatch(url);
+ httpPatch.setEntity(theEntity);
+ return httpPatch;
+ case OPTIONS:
+ return new HttpOptions(url);
+ case POST:
+ HttpPost httpPost = new HttpPost(url);
+ httpPost.setEntity(theEntity);
+ return httpPost;
+ case PUT:
+ HttpPut httpPut = new HttpPut(url);
+ httpPut.setEntity(theEntity);
+ return httpPut;
+ case GET:
+ default:
+ return new HttpGet(url);
+ }
+ }
+
+ private UrlEncodedFormEntity createFormEntity(List parameters) {
+ return new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
+ }
+
+ @Override
+ protected IHttpRequest createHttpRequest() {
+ return createHttpRequest((HttpEntity) null);
+ }
+
+ @Override
+ protected IHttpRequest createHttpRequest(byte[] content) {
+ /*
+ * Note: Be careful about changing which constructor we use for
+ * ByteArrayEntity, as Android's version of HTTPClient doesn't support
+ * the newer ones for whatever reason.
+ */
+ ByteArrayEntity entity = new ByteArrayEntity(content, APPLICATION_OCTET_STREAM);
+ return createHttpRequest(entity);
+ }
+
+ private ApacheHttp5Request createHttpRequest(HttpEntity theEntity) {
+ HttpUriRequest request = constructRequestBase(theEntity);
+ return new ApacheHttp5Request(myClient, request);
+ }
+
+ @Override
+ protected IHttpRequest createHttpRequest(Map> theParams) {
+ List parameters = new ArrayList<>();
+ for (Entry> nextParam : theParams.entrySet()) {
+ List value = nextParam.getValue();
+ for (String s : value) {
+ parameters.add(new BasicNameValuePair(nextParam.getKey(), s));
+ }
+ }
+
+ UrlEncodedFormEntity entity = createFormEntity(parameters);
+ return createHttpRequest(entity);
+ }
+
+ @Override
+ protected IHttpRequest createHttpRequest(String theContents) {
+ /*
+ * We aren't using a StringEntity here because the constructors
+ * supported by Android aren't available in non-Android, and vice versa.
+ * Since we add the content type header manually, it makes no difference
+ * which one we use anyhow.
+ */
+ ByteArrayEntity entity =
+ new ByteArrayEntity(theContents.getBytes(StandardCharsets.UTF_8), APPLICATION_OCTET_STREAM);
+ return createHttpRequest(entity);
+ }
+}
diff --git a/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5RestfulClientFactory.java b/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5RestfulClientFactory.java
new file mode 100644
index 00000000000..979d0910169
--- /dev/null
+++ b/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5RestfulClientFactory.java
@@ -0,0 +1,170 @@
+/*
+ * #%L
+ * HAPI FHIR - Client Framework
+ * %%
+ * Copyright (C) 2014 - 2024 Smile CDR, Inc.
+ * %%
+ * 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.
+ * #L%
+ */
+package ca.uhn.fhir.rest.client.apache;
+
+import ca.uhn.fhir.context.FhirContext;
+import ca.uhn.fhir.rest.api.RequestTypeEnum;
+import ca.uhn.fhir.rest.client.api.Header;
+import ca.uhn.fhir.rest.client.api.IHttpClient;
+import ca.uhn.fhir.rest.client.impl.RestfulClientFactory;
+import org.apache.hc.client5.http.auth.AuthScope;
+import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.client5.http.config.ConnectionConfig;
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.impl.DefaultAuthenticationStrategy;
+import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider;
+import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.io.SocketConfig;
+import org.apache.hc.core5.util.TimeValue;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.apache.commons.lang3.StringUtils.isNotBlank;
+
+/**
+ * A Restful Factory to create clients, requests and responses based on the Apache httpclient library.
+ *
+ * @author Peter Van Houte | peter.vanhoute@agfa.com | Agfa Healthcare
+ */
+public class ApacheHttp5RestfulClientFactory extends RestfulClientFactory {
+
+ private HttpClient myHttpClient;
+ private HttpHost myProxy;
+
+ /**
+ * Constructor
+ */
+ public ApacheHttp5RestfulClientFactory() {
+ super();
+ }
+
+ /**
+ * Constructor
+ *
+ * @param theContext
+ * The context
+ */
+ public ApacheHttp5RestfulClientFactory(FhirContext theContext) {
+ super(theContext);
+ }
+
+ @Override
+ protected synchronized IHttpClient getHttpClient(String theServerBase) {
+ return getHttpClient(new StringBuilder(theServerBase), null, null, null, null);
+ }
+
+ @Override
+ public synchronized IHttpClient getHttpClient(
+ StringBuilder theUrl,
+ Map> theIfNoneExistParams,
+ String theIfNoneExistString,
+ RequestTypeEnum theRequestType,
+ List theHeaders) {
+ return new ApacheHttp5RestfulClient(
+ getNativeHttpClient(), theUrl, theIfNoneExistParams, theIfNoneExistString, theRequestType, theHeaders);
+ }
+
+ public HttpClient getNativeHttpClient() {
+ if (myHttpClient == null) {
+ ConnectionConfig connectionConfig = ConnectionConfig.custom()
+ .setConnectTimeout(getConnectTimeout(), TimeUnit.MILLISECONDS)
+ .build();
+
+ RequestConfig defaultRequestConfig = RequestConfig.custom()
+ .setResponseTimeout(getSocketTimeout(), TimeUnit.MILLISECONDS)
+ .setConnectionRequestTimeout(getConnectionRequestTimeout(), TimeUnit.MILLISECONDS)
+ .build();
+
+ SocketConfig socketConfig = SocketConfig.custom()
+ .setSoTimeout(getSocketTimeout(), TimeUnit.MILLISECONDS)
+ .build();
+
+ HttpClientBuilder builder = getHttpClientBuilder()
+ .useSystemProperties()
+ .setDefaultRequestConfig(defaultRequestConfig)
+ .disableCookieManagement();
+
+ PoolingHttpClientConnectionManager connectionManager =
+ createPoolingHttpClientConnectionManager(socketConfig, connectionConfig);
+ builder.setConnectionManager(connectionManager);
+
+ if (myProxy != null && isNotBlank(getProxyUsername()) && isNotBlank(getProxyPassword())) {
+ BasicCredentialsProvider credsProvider = new BasicCredentialsProvider();
+ credsProvider.setCredentials(
+ new AuthScope(myProxy.getHostName(), myProxy.getPort()),
+ new UsernamePasswordCredentials(
+ getProxyUsername(), getProxyPassword().toCharArray()));
+ builder.setProxyAuthenticationStrategy(new DefaultAuthenticationStrategy());
+ builder.setDefaultCredentialsProvider(credsProvider);
+ }
+
+ myHttpClient = builder.build();
+ }
+
+ return myHttpClient;
+ }
+
+ private PoolingHttpClientConnectionManager createPoolingHttpClientConnectionManager(
+ SocketConfig socketConfig, ConnectionConfig connectionConfig) {
+ PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
+ connectionManager.setMaxTotal(getPoolMaxTotal());
+ connectionManager.setDefaultMaxPerRoute(getPoolMaxPerRoute());
+ connectionManager.setDefaultSocketConfig(socketConfig);
+ connectionManager.setDefaultConnectionConfig(connectionConfig);
+ connectionManager.setConnectionConfigResolver(route -> ConnectionConfig.custom()
+ .setValidateAfterInactivity(
+ TimeValue.ofSeconds(5)) // Validate connections after 5 seconds of inactivity
+ .build());
+ return connectionManager;
+ }
+
+ protected HttpClientBuilder getHttpClientBuilder() {
+ return HttpClients.custom();
+ }
+
+ @Override
+ protected void resetHttpClient() {
+ this.myHttpClient = null;
+ }
+
+ /**
+ * Only allows to set an instance of type org.apache.hc.client5.http.classic.HttpClient
+ * @see ca.uhn.fhir.rest.client.api.IRestfulClientFactory#setHttpClient(Object)
+ */
+ @Override
+ public synchronized void setHttpClient(Object theHttpClient) {
+ this.myHttpClient = (HttpClient) theHttpClient;
+ }
+
+ @Override
+ public void setProxy(String theHost, Integer thePort) {
+ if (theHost != null) {
+ myProxy = new HttpHost("http", theHost, thePort);
+ } else {
+ myProxy = null;
+ }
+ }
+}
diff --git a/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/tls/ApacheHttp5TlsAuthenticationSvc.java b/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/tls/ApacheHttp5TlsAuthenticationSvc.java
new file mode 100644
index 00000000000..b791f8dd122
--- /dev/null
+++ b/hapi-fhir-client-apache-http5/src/main/java/ca/uhn/fhir/rest/client/tls/ApacheHttp5TlsAuthenticationSvc.java
@@ -0,0 +1,148 @@
+/*-
+ * #%L
+ * HAPI FHIR - Client Framework
+ * %%
+ * Copyright (C) 2014 - 2024 Smile CDR, Inc.
+ * %%
+ * 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.
+ * #L%
+ */
+package ca.uhn.fhir.rest.client.tls;
+
+import ca.uhn.fhir.i18n.Msg;
+import ca.uhn.fhir.tls.BaseStoreInfo;
+import ca.uhn.fhir.tls.KeyStoreInfo;
+import ca.uhn.fhir.tls.PathType;
+import ca.uhn.fhir.tls.TlsAuthentication;
+import ca.uhn.fhir.tls.TrustStoreInfo;
+import jakarta.annotation.Nonnull;
+import org.apache.commons.lang3.Validate;
+import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier;
+import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
+import org.apache.hc.client5.http.ssl.TrustSelfSignedStrategy;
+import org.apache.hc.core5.ssl.PrivateKeyStrategy;
+import org.apache.hc.core5.ssl.SSLContextBuilder;
+import org.apache.hc.core5.ssl.SSLContexts;
+
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.security.KeyStore;
+import java.util.Optional;
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509TrustManager;
+
+import static org.apache.commons.lang3.StringUtils.isNotBlank;
+
+public class ApacheHttp5TlsAuthenticationSvc {
+
+ private ApacheHttp5TlsAuthenticationSvc() {}
+
+ public static SSLContext createSslContext(@Nonnull TlsAuthentication theTlsAuthentication) {
+ Validate.notNull(theTlsAuthentication, "theTlsAuthentication cannot be null");
+
+ try {
+ SSLContextBuilder contextBuilder = SSLContexts.custom();
+
+ if (theTlsAuthentication.getKeyStoreInfo().isPresent()) {
+ KeyStoreInfo keyStoreInfo =
+ theTlsAuthentication.getKeyStoreInfo().get();
+ PrivateKeyStrategy privateKeyStrategy = null;
+ if (isNotBlank(keyStoreInfo.getAlias())) {
+ privateKeyStrategy = (aliases, socket) -> keyStoreInfo.getAlias();
+ }
+ KeyStore keyStore = createKeyStore(keyStoreInfo);
+ contextBuilder.loadKeyMaterial(keyStore, keyStoreInfo.getKeyPass(), privateKeyStrategy);
+ }
+
+ if (theTlsAuthentication.getTrustStoreInfo().isPresent()) {
+ TrustStoreInfo trustStoreInfo =
+ theTlsAuthentication.getTrustStoreInfo().get();
+ KeyStore trustStore = createKeyStore(trustStoreInfo);
+ contextBuilder.loadTrustMaterial(trustStore, TrustSelfSignedStrategy.INSTANCE);
+ }
+
+ return contextBuilder.build();
+ } catch (Exception e) {
+ throw new TlsAuthenticationException(Msg.code(2575) + "Failed to create SSLContext", e);
+ }
+ }
+
+ public static KeyStore createKeyStore(BaseStoreInfo theStoreInfo) {
+ try {
+ KeyStore keyStore = KeyStore.getInstance(theStoreInfo.getType().toString());
+
+ if (PathType.RESOURCE.equals(theStoreInfo.getPathType())) {
+ try (InputStream inputStream =
+ ApacheHttp5TlsAuthenticationSvc.class.getResourceAsStream(theStoreInfo.getFilePath())) {
+ validateKeyStoreExists(inputStream);
+ keyStore.load(inputStream, theStoreInfo.getStorePass());
+ }
+ } else if (PathType.FILE.equals(theStoreInfo.getPathType())) {
+ try (InputStream inputStream = new FileInputStream(theStoreInfo.getFilePath())) {
+ validateKeyStoreExists(inputStream);
+ keyStore.load(inputStream, theStoreInfo.getStorePass());
+ }
+ }
+ return keyStore;
+ } catch (Exception e) {
+ throw new TlsAuthenticationException(Msg.code(2576) + "Failed to create KeyStore", e);
+ }
+ }
+
+ public static void validateKeyStoreExists(InputStream theInputStream) {
+ if (theInputStream == null) {
+ throw new TlsAuthenticationException(Msg.code(2577) + "Keystore does not exists");
+ }
+ }
+
+ public static X509TrustManager createTrustManager(Optional theTrustStoreInfo) {
+ try {
+ TrustManagerFactory trustManagerFactory =
+ TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ if (!theTrustStoreInfo.isPresent()) {
+ trustManagerFactory.init((KeyStore) null); // Load Trust Manager Factory with default Java truststore
+ } else {
+ TrustStoreInfo trustStoreInfo = theTrustStoreInfo.get();
+ KeyStore trustStore = createKeyStore(trustStoreInfo);
+ trustManagerFactory.init(trustStore);
+ }
+ for (TrustManager trustManager : trustManagerFactory.getTrustManagers()) {
+ if (trustManager instanceof X509TrustManager) {
+ return (X509TrustManager) trustManager;
+ }
+ }
+ throw new TlsAuthenticationException(Msg.code(2578) + "Could not find X509TrustManager");
+ } catch (Exception e) {
+ throw new TlsAuthenticationException(Msg.code(2579) + "Failed to create X509TrustManager");
+ }
+ }
+
+ public static HostnameVerifier createHostnameVerifier(Optional theTrustStoreInfo) {
+ return theTrustStoreInfo.isPresent() ? new DefaultHostnameVerifier() : new NoopHostnameVerifier();
+ }
+
+ public static class TlsAuthenticationException extends RuntimeException {
+ private static final long serialVersionUID = 1l;
+
+ public TlsAuthenticationException(String theMessage, Throwable theCause) {
+ super(theMessage, theCause);
+ }
+
+ public TlsAuthenticationException(String theMessage) {
+ super(theMessage);
+ }
+ }
+}
diff --git a/hapi-fhir-client-apache-http5/src/test/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5RequestTest.java b/hapi-fhir-client-apache-http5/src/test/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5RequestTest.java
new file mode 100644
index 00000000000..0ea69159bad
--- /dev/null
+++ b/hapi-fhir-client-apache-http5/src/test/java/ca/uhn/fhir/rest/client/apache/ApacheHttp5RequestTest.java
@@ -0,0 +1,40 @@
+package ca.uhn.fhir.rest.client.apache;
+
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class ApacheHttp5RequestTest {
+
+ private final String ENTITY_CONTENT = "Some entity with special characters: é";
+ private StringEntity entity;
+ private final HttpPost request = new HttpPost("");
+
+ @Test
+ public void testGetRequestBodyFromStream() throws IOException {
+ entity = new StringEntity(ENTITY_CONTENT, StandardCharsets.ISO_8859_1);
+ request.setHeader("Content-type", "text/plain; charset=ISO-8859-1");
+ request.setEntity(entity);
+
+ String result = new ApacheHttp5Request(null, request).getRequestBodyFromStream();
+
+ assertEquals(ENTITY_CONTENT, result);
+ }
+
+ @Test
+ public void testGetRequestBodyFromStreamWithDefaultCharset() throws IOException {
+ entity = new StringEntity(ENTITY_CONTENT, Charset.defaultCharset());
+ request.setHeader("Content-type", "text/plain");
+ request.setEntity(entity);
+
+ String result = new ApacheHttp5Request(null, request).getRequestBodyFromStream();
+
+ assertEquals(ENTITY_CONTENT, result);
+ }
+}
diff --git a/hapi-fhir-client-apache-http5/src/test/java/ca/uhn/fhir/rest/client/tls/ApacheHttp5TlsAuthenticationSvcTest.java b/hapi-fhir-client-apache-http5/src/test/java/ca/uhn/fhir/rest/client/tls/ApacheHttp5TlsAuthenticationSvcTest.java
new file mode 100644
index 00000000000..42caea25cbf
--- /dev/null
+++ b/hapi-fhir-client-apache-http5/src/test/java/ca/uhn/fhir/rest/client/tls/ApacheHttp5TlsAuthenticationSvcTest.java
@@ -0,0 +1,158 @@
+package ca.uhn.fhir.rest.client.tls;
+
+import ca.uhn.fhir.i18n.Msg;
+import ca.uhn.fhir.tls.KeyStoreInfo;
+import ca.uhn.fhir.tls.TlsAuthentication;
+import ca.uhn.fhir.tls.TrustStoreInfo;
+import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier;
+import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.X509TrustManager;
+import java.security.KeyStore;
+import java.security.cert.Certificate;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+
+
+public class ApacheHttp5TlsAuthenticationSvcTest {
+
+ private KeyStoreInfo myServerKeyStoreInfo;
+ private TrustStoreInfo myServerTrustStoreInfo;
+ private TlsAuthentication myServerTlsAuthentication;
+
+ private KeyStoreInfo myClientKeyStoreInfo;
+ private TrustStoreInfo myClientTrustStoreInfo;
+ private TlsAuthentication myClientTlsAuthentication;
+
+ @BeforeEach
+ public void beforeEach(){
+ myServerKeyStoreInfo = new KeyStoreInfo("classpath:/server-keystore.p12", "changeit", "changeit", "server");
+ myServerTrustStoreInfo = new TrustStoreInfo("classpath:/server-truststore.p12", "changeit", "client");
+ myServerTlsAuthentication = new TlsAuthentication(Optional.of(myServerKeyStoreInfo), Optional.of(myServerTrustStoreInfo));
+
+ myClientKeyStoreInfo = new KeyStoreInfo("classpath:/client-keystore.p12", "changeit", "changeit", "client");
+ myClientTrustStoreInfo = new TrustStoreInfo("classpath:/client-truststore.p12", "changeit", "server");
+ myClientTlsAuthentication = new TlsAuthentication(Optional.of(myClientKeyStoreInfo), Optional.of(myClientTrustStoreInfo));
+ }
+
+ @Test
+ public void testCreateSslContextEmpty(){
+ TlsAuthentication emptyAuthentication = null;
+ try {
+ ApacheHttp5TlsAuthenticationSvc.createSslContext(emptyAuthentication);
+ fail(); } catch (Exception e) {
+ assertEquals("theTlsAuthentication cannot be null", e.getMessage());
+ }
+ }
+
+ @Test
+ public void testCreateSslContextPresent(){
+ SSLContext result = ApacheHttp5TlsAuthenticationSvc.createSslContext(myServerTlsAuthentication);
+ assertEquals("TLS", result.getProtocol());
+ }
+
+ @Test
+ public void testCreateSslContextPresentInvalid(){
+ KeyStoreInfo invalidKeyStoreInfo = new KeyStoreInfo("file:///INVALID.p12", "changeit", "changeit", "server");
+ TlsAuthentication invalidTlsAuthentication = new TlsAuthentication(Optional.of(invalidKeyStoreInfo), Optional.of(myServerTrustStoreInfo));
+ try {
+ ApacheHttp5TlsAuthenticationSvc.createSslContext(invalidTlsAuthentication);
+ fail(); } catch (Exception e) {
+ assertEquals(Msg.code(2575) + "Failed to create SSLContext", e.getMessage());
+ }
+ }
+
+ @Test
+ public void testCreateKeyStoreP12() throws Exception {
+ KeyStore keyStore = ApacheHttp5TlsAuthenticationSvc.createKeyStore(myServerKeyStoreInfo);
+ assertNotNull(keyStore.getKey(myServerKeyStoreInfo.getAlias(), myServerKeyStoreInfo.getKeyPass()));
+ }
+
+ @Test
+ public void testCreateKeyStoreJKS() throws Exception {
+ KeyStoreInfo keyStoreInfo = new KeyStoreInfo("classpath:/keystore.jks", "changeit", "changeit", "client");
+ KeyStore keyStore = ApacheHttp5TlsAuthenticationSvc.createKeyStore(keyStoreInfo);
+ assertNotNull(keyStore.getKey(keyStoreInfo.getAlias(), keyStoreInfo.getKeyPass()));
+ }
+
+ @Test
+ public void testCreateKeyStoreNonExistentFile() {
+ KeyStoreInfo keyStoreInfo = new KeyStoreInfo("classpath:/non-existent.p12", "changeit", "changeit", "server");
+ try {
+ ApacheHttp5TlsAuthenticationSvc.createKeyStore(keyStoreInfo);
+ fail(); }
+ catch (Exception e) {
+ assertEquals(Msg.code(2576) + "Failed to create KeyStore", e.getMessage());
+ }
+ }
+
+ @Test
+ public void testCreateTrustStoreJks() throws Exception {
+ TrustStoreInfo trustStoreInfo = new TrustStoreInfo("classpath:/truststore.jks", "changeit", "client");
+ KeyStore keyStore = ApacheHttp5TlsAuthenticationSvc.createKeyStore(trustStoreInfo);
+ assertNotNull(keyStore.getCertificate(trustStoreInfo.getAlias()));
+ }
+
+ @Test
+ public void testCreateTrustStoreP12() throws Exception {
+ KeyStore keyStore = ApacheHttp5TlsAuthenticationSvc.createKeyStore(myServerTrustStoreInfo);
+ assertNotNull(keyStore.getCertificate(myServerTrustStoreInfo.getAlias()));
+ }
+
+ @Test
+ public void testCreateTrustStoreNonExistentFile() throws Exception {
+ TrustStoreInfo trustStoreInfo = new TrustStoreInfo("classpath:/non-existent.p12", "changeit", "server");
+ try {
+ ApacheHttp5TlsAuthenticationSvc.createKeyStore(trustStoreInfo);
+ fail(); } catch (Exception e) {
+ assertEquals(Msg.code(2576) + "Failed to create KeyStore", e.getMessage());
+ }
+ }
+
+ @Test
+ public void testCreateTrustManager() throws Exception {
+ X509TrustManager trustManager = ApacheHttp5TlsAuthenticationSvc.createTrustManager(Optional.of(myClientTrustStoreInfo));
+ KeyStore keyStore = ApacheHttp5TlsAuthenticationSvc.createKeyStore(myServerKeyStoreInfo);
+ Certificate serverCertificate = keyStore.getCertificate(myServerKeyStoreInfo.getAlias());
+
+ assertEquals(1, trustManager.getAcceptedIssuers().length);
+ assertEquals(serverCertificate, trustManager.getAcceptedIssuers()[0]);
+ }
+
+ @Test
+ public void testCreateTrustManagerNoTrustStore() {
+ // trust manager should contain common certifications if no trust store information is used
+ X509TrustManager trustManager = ApacheHttp5TlsAuthenticationSvc.createTrustManager(Optional.empty());
+ assertThat(trustManager.getAcceptedIssuers().length).isNotEqualTo(0);
+ }
+
+ @Test
+ public void testCreateTrustManagerInvalid() {
+ TrustStoreInfo invalidKeyStoreInfo = new TrustStoreInfo("file:///INVALID.p12", "changeit", "client");
+ try {
+ ApacheHttp5TlsAuthenticationSvc.createTrustManager(Optional.of(invalidKeyStoreInfo));
+ fail(); } catch (Exception e) {
+ assertEquals(Msg.code(2579) + "Failed to create X509TrustManager", e.getMessage());
+ }
+ }
+
+ @Test
+ public void testCreateHostnameVerifierEmptyTrustStoreInfo(){
+ Optional trustStoreInfo = Optional.empty();
+ HostnameVerifier result = ApacheHttp5TlsAuthenticationSvc.createHostnameVerifier(trustStoreInfo);
+ assertEquals(NoopHostnameVerifier.class, result.getClass());
+ }
+
+ @Test
+ public void testCreateHostnameVerifierPresentTrustStoreInfo(){
+ Optional trustStoreInfo = Optional.of(myServerTrustStoreInfo);
+ HostnameVerifier result = ApacheHttp5TlsAuthenticationSvc.createHostnameVerifier(trustStoreInfo);
+ assertEquals(DefaultHostnameVerifier.class, result.getClass());
+ }
+}
diff --git a/hapi-fhir-client-apache-http5/src/test/resources/client-keystore.p12 b/hapi-fhir-client-apache-http5/src/test/resources/client-keystore.p12
new file mode 100644
index 00000000000..c169ab68278
Binary files /dev/null and b/hapi-fhir-client-apache-http5/src/test/resources/client-keystore.p12 differ
diff --git a/hapi-fhir-client-apache-http5/src/test/resources/client-truststore.p12 b/hapi-fhir-client-apache-http5/src/test/resources/client-truststore.p12
new file mode 100644
index 00000000000..d12c7199708
Binary files /dev/null and b/hapi-fhir-client-apache-http5/src/test/resources/client-truststore.p12 differ
diff --git a/hapi-fhir-client-apache-http5/src/test/resources/keystore.jks b/hapi-fhir-client-apache-http5/src/test/resources/keystore.jks
new file mode 100644
index 00000000000..ccce89d0aba
Binary files /dev/null and b/hapi-fhir-client-apache-http5/src/test/resources/keystore.jks differ
diff --git a/hapi-fhir-client-apache-http5/src/test/resources/server-keystore.p12 b/hapi-fhir-client-apache-http5/src/test/resources/server-keystore.p12
new file mode 100644
index 00000000000..00ae45348ef
Binary files /dev/null and b/hapi-fhir-client-apache-http5/src/test/resources/server-keystore.p12 differ
diff --git a/hapi-fhir-client-apache-http5/src/test/resources/server-truststore.p12 b/hapi-fhir-client-apache-http5/src/test/resources/server-truststore.p12
new file mode 100644
index 00000000000..65575b455b6
Binary files /dev/null and b/hapi-fhir-client-apache-http5/src/test/resources/server-truststore.p12 differ
diff --git a/hapi-fhir-client-apache-http5/src/test/resources/truststore.jks b/hapi-fhir-client-apache-http5/src/test/resources/truststore.jks
new file mode 100644
index 00000000000..f4fa4000747
Binary files /dev/null and b/hapi-fhir-client-apache-http5/src/test/resources/truststore.jks differ
diff --git a/hapi-fhir-docs/pom.xml b/hapi-fhir-docs/pom.xml
index 79b067aafd4..0a55640077f 100644
--- a/hapi-fhir-docs/pom.xml
+++ b/hapi-fhir-docs/pom.xml
@@ -41,6 +41,11 @@
hapi-fhir-client
${project.version}
+
+ ca.uhn.hapi.fhir
+ hapi-fhir-client-apache-http5
+ ${project.version}
+
ca.uhn.hapi.fhir
hapi-fhir-client-okhttp
diff --git a/hapi-fhir-jacoco/pom.xml b/hapi-fhir-jacoco/pom.xml
index 86e3fd4383b..dac274bae96 100644
--- a/hapi-fhir-jacoco/pom.xml
+++ b/hapi-fhir-jacoco/pom.xml
@@ -162,6 +162,11 @@
${project.version}
-->
+
+ ca.uhn.hapi.fhir
+ hapi-fhir-client-apache-http5
+ ${project.version}
+
ca.uhn.hapi.fhir
hapi-fhir-client-okhttp
diff --git a/hapi-fhir-spring-boot/hapi-fhir-spring-boot-autoconfigure/pom.xml b/hapi-fhir-spring-boot/hapi-fhir-spring-boot-autoconfigure/pom.xml
index 5f2a71be410..49c7e0eea55 100644
--- a/hapi-fhir-spring-boot/hapi-fhir-spring-boot-autoconfigure/pom.xml
+++ b/hapi-fhir-spring-boot/hapi-fhir-spring-boot-autoconfigure/pom.xml
@@ -53,6 +53,12 @@
${project.version}
true
+
+ ca.uhn.hapi.fhir
+ hapi-fhir-client-apache-http5
+ ${project.version}
+ true
+
ca.uhn.hapi.fhir
hapi-fhir-client-okhttp
diff --git a/pom.xml b/pom.xml
index 1cba1096550..82e20acaf66 100644
--- a/pom.xml
+++ b/pom.xml
@@ -115,6 +115,7 @@
hapi-fhir-jpaserver-mdm
hapi-fhir-testpage-overlay
hapi-fhir-jpaserver-uhnfhirtest
+ hapi-fhir-client-apache-http5
hapi-fhir-client-okhttp
hapi-fhir-android
hapi-fhir-cli
@@ -125,6 +126,7 @@
hapi-fhir-spring-boot
hapi-fhir-jacoco
hapi-fhir-server-cds-hooks
+
@@ -1029,6 +1031,8 @@
8.0.0.Final
4.4.13
4.5.13
+ 5.4.1
+ 5.3.1
2.17.1
2.17.1
3.3.0
@@ -1560,6 +1564,16 @@
commons-text
${commons_text_version}
+
+ org.apache.httpcomponents.core5
+ httpcore5
+ ${httpcore5_version}
+
+
+ org.apache.httpcomponents.client5
+ httpclient5
+ ${httpclient5_version}
+
org.apache.httpcomponents
httpclient