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}