001/*-
002 * #%L
003 * Smile CDR - CDR
004 * %%
005 * Copyright (C) 2016 - 2026 Smile CDR, Inc.
006 * %%
007 * All rights reserved.
008 * #L%
009 */
010package ca.cdr.test.app.clients.common;
011
012import ca.uhn.fhir.context.FhirContext;
013import ca.uhn.fhir.test.utilities.HttpTestRequest;
014import jakarta.annotation.Nonnull;
015import org.apache.hc.client5.http.config.ConnectionConfig;
016import org.apache.hc.client5.http.config.RequestConfig;
017import org.apache.hc.client5.http.cookie.BasicCookieStore;
018import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
019import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
020import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
021import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
022import org.apache.hc.core5.pool.PoolConcurrencyPolicy;
023import org.apache.hc.core5.pool.PoolReusePolicy;
024import org.apache.hc.core5.util.TimeValue;
025
026import java.io.IOException;
027import java.io.UncheckedIOException;
028import java.util.concurrent.TimeUnit;
029
030/**
031 * An Apache HttpClient 5.x client configured the way Smile's tests need it, bundled with the
032 * mutable state that callers have to be able to reach ? its cookie store and its connection pool.
033 * <p>
034 * The configuration is deliberately identical everywhere: generous timeouts (a debugging test
035 * should not fail on a socket read), a pool large enough that a test making many concurrent calls
036 * does not queue on itself, and <b>redirects disabled</b> so that a test can assert on a 302 and
037 * its {@literal Location} header rather than silently following it.
038 * <p>
039 * The cookie store is shared across every request made through {@link #request(String)}, which is
040 * what makes multi-request login flows (SMART/OAuth) work. Tests that need to start a fresh session
041 * call {@link #clearCookies()}.
042 * <p>
043 * {@link #cookielessRequest(String)} is the deliberate exception: it neither sends nor stores
044 * cookies, so a test asserting that a request is rejected without credentials cannot be handed a
045 * pass by a session some earlier request established. Both kinds of request share the same
046 * connection pool.
047 * <p>
048 * Code that only needs to issue requests should take {@link ISmileTestHttpClient} instead of this
049 * class. That interface omits {@link #close()} and {@link #getClient()} and is not
050 * {@link AutoCloseable}, so a caller cannot <em>accidentally</em> shut down a pool it does not own.
051 */
052// Created by claude-opus-5
053public final class SmileTestHttpClient implements ISmileTestHttpClient, AutoCloseable {
054
055        private static final int MAX_CONNECTIONS = 99;
056        private static final TimeValue CONNECTION_TIME_TO_LIVE = TimeValue.of(5, TimeUnit.SECONDS);
057        private static final int DEFAULT_TIMEOUT_IN_MINUTES = 10;
058        private static final TimeUnit TIMEOUT_UNIT = TimeUnit.MINUTES;
059
060        private final CloseableHttpClient myClient;
061        private final CloseableHttpClient myCookielessClient;
062        private final PoolingHttpClientConnectionManager myConnectionManager;
063        private final BasicCookieStore myCookieStore;
064        private volatile boolean myClosed;
065
066        private SmileTestHttpClient(
067                        CloseableHttpClient theClient,
068                        CloseableHttpClient theCookielessClient,
069                        PoolingHttpClientConnectionManager theConnectionManager,
070                        BasicCookieStore theCookieStore) {
071                myClient = theClient;
072                myCookielessClient = theCookielessClient;
073                myConnectionManager = theConnectionManager;
074                myCookieStore = theCookieStore;
075        }
076
077        /**
078         * Builds a new client with its own connection pool and cookie store. The caller owns the
079         * returned instance and must {@link #close()} it.
080         */
081        public static @Nonnull SmileTestHttpClient create() {
082                PoolingHttpClientConnectionManager connectionManager = buildConnectionManager();
083                BasicCookieStore cookieStore = new BasicCookieStore();
084
085                CloseableHttpClient client =
086                        newClientBuilder(connectionManager).setDefaultCookieStore(cookieStore).build();
087                CloseableHttpClient cookielessClient =
088                        newClientBuilder(connectionManager).disableCookieManagement().build();
089
090                return new SmileTestHttpClient(client, cookielessClient, connectionManager, cookieStore);
091        }
092
093        /**
094         * The connection manager is marked shared so that closing either client leaves the pool alone;
095         * {@link #close()} shuts the pool down once, after both clients are closed.
096         */
097        private static @Nonnull HttpClientBuilder newClientBuilder(PoolingHttpClientConnectionManager theConnectionManager) {
098                return HttpClientBuilder.create()
099                        .setConnectionManager(theConnectionManager)
100                        .setConnectionManagerShared(true)
101                        .disableRedirectHandling()
102                        .setDefaultRequestConfig(RequestConfig.custom()
103                                .setConnectionRequestTimeout(DEFAULT_TIMEOUT_IN_MINUTES, TIMEOUT_UNIT)
104                                .build());
105        }
106
107        /**
108         * Registers no socket factories: HTTPS goes through the builder's default
109         * {@code DefaultClientTlsStrategy}, which uses the system-default SSL context and the default
110         * hostname verifier ? so {@code javax.net.ssl.*} system properties are what point a test at a
111         * container's truststore.
112         */
113        private static @Nonnull PoolingHttpClientConnectionManager buildConnectionManager() {
114                return PoolingHttpClientConnectionManagerBuilder.create()
115                        .setPoolConcurrencyPolicy(PoolConcurrencyPolicy.LAX)
116                        .setConnPoolPolicy(PoolReusePolicy.LIFO)
117                        .setMaxConnTotal(MAX_CONNECTIONS)
118                        .setMaxConnPerRoute(MAX_CONNECTIONS)
119                        .setDefaultConnectionConfig(ConnectionConfig.custom()
120                                .setTimeToLive(CONNECTION_TIME_TO_LIVE)
121                                .setConnectTimeout(DEFAULT_TIMEOUT_IN_MINUTES, TIMEOUT_UNIT)
122                                .setSocketTimeout(DEFAULT_TIMEOUT_IN_MINUTES, TIMEOUT_UNIT)
123                                .build())
124                        .build();
125        }
126
127        /**
128         * The underlying Apache client, for callers that still need to build Apache request objects by
129         * hand. Deliberately absent from {@link ISmileTestHttpClient}, because a caller holding this can
130         * close the pool out from under every other user of this client. It is the cookie-carrying
131         * client, so requests built on it share the session with {@link #request(String)}.
132         */
133        public @Nonnull CloseableHttpClient getClient() {
134                return myClient;
135        }
136
137        @Override
138        public @Nonnull HttpTestRequest request(@Nonnull String theUrl) {
139                return HttpTestRequest.to(myClient, theUrl);
140        }
141
142        @Override
143        public @Nonnull HttpTestRequest request(@Nonnull FhirContext theFhirContext, @Nonnull String theUrl) {
144                return HttpTestRequest.to(myClient, theFhirContext, theUrl);
145        }
146
147        @Override
148        public @Nonnull HttpTestRequest cookielessRequest(@Nonnull String theUrl) {
149                return HttpTestRequest.to(myCookielessClient, theUrl);
150        }
151
152        @Override
153        public @Nonnull HttpTestRequest cookielessRequest(@Nonnull FhirContext theFhirContext, @Nonnull String theUrl) {
154                return HttpTestRequest.to(myCookielessClient, theFhirContext, theUrl);
155        }
156
157        @Override
158        public void clearCookies() {
159                myCookieStore.clear();
160        }
161
162        /**
163         * Whether {@link #close()} has been called. A holder that caches this client can use it to tell
164         * a live client from one a caller has already shut down.
165         */
166        public boolean isClosed() {
167                return myClosed;
168        }
169
170        /**
171         * Closes both underlying clients and the connection pool they share.
172         *
173         * @throws UncheckedIOException if a client fails to close. Callers are tests, and a failure
174         *    here is an infrastructure problem rather than a case under test, so it is not a checked
175         *    exception that every {@code @AfterAll} would have to declare.
176         */
177        @Override
178        public void close() {
179                myClosed = true;
180                IOException failure;
181                IOException cookielessFailure;
182                try {
183                        failure = closeQuietly(myClient);
184                        cookielessFailure = closeQuietly(myCookielessClient);
185                } finally {
186                        // closeQuietly carries IOExceptions back, but a client can still fail unchecked; the pool
187                        // has to come down either way, or the sockets outlive the JVM's interest in them.
188                        myConnectionManager.close();
189                }
190
191                IOException toReport = failure != null ? failure : cookielessFailure;
192                if (toReport != null) {
193                        if (failure != null && cookielessFailure != null) {
194                                failure.addSuppressed(cookielessFailure);
195                        }
196                        throw new UncheckedIOException(toReport);
197                }
198        }
199
200        /**
201         * Closing one client must not leave the other open, so the failure is carried back rather than
202         * thrown from the middle of the sequence.
203         */
204        private static IOException closeQuietly(CloseableHttpClient theClient) {
205                try {
206                        theClient.close();
207                        return null;
208                } catch (IOException e) {
209                        return e;
210                }
211        }
212}