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.harness.impl;
011
012import ca.cdr.test.app.clients.AdminJsonRestClient;
013import ca.cdr.test.app.clients.CdrFhirClient;
014import ca.cdr.test.app.clients.CdsHooksClient;
015import ca.cdr.test.app.clients.HL7V2RestClient;
016import ca.cdr.test.app.clients.NpmPackageClient;
017import ca.cdr.test.app.clients.OutboundSmartClient;
018import ca.cdr.test.app.clients.SmartHealthLinkClient;
019import ca.cdr.test.app.clients.SmilePortalClient;
020import ca.cdr.test.app.clients.common.ISmileTestHttpClient;
021import ca.cdr.test.app.clients.common.RequestFactoryUtil;
022import ca.cdr.test.app.clients.common.SmileTestHttpClient;
023import ca.cdr.test.app.harness.api.HarnessContext;
024import ca.cdr.test.app.harness.api.SmileHarness;
025import ca.cdr.test.model.NodeConfigurations;
026import ca.cdr.test.util.UrlPathUtil;
027import ca.uhn.fhir.context.FhirContext;
028import ca.uhn.fhir.context.FhirVersionEnum;
029import ca.uhn.fhir.rest.client.api.IClientInterceptor;
030import ca.uhn.fhir.rest.client.api.IGenericClient;
031import ca.uhn.fhir.rest.client.interceptor.BasicAuthInterceptor;
032import ca.uhn.fhir.rest.client.interceptor.BearerTokenAuthInterceptor;
033import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
034import ca.uhn.fhir.test.utilities.HttpTestRequest;
035import jakarta.annotation.Nonnull;
036import jakarta.annotation.Nullable;
037import org.apache.commons.lang3.StringUtils;
038import org.apache.commons.lang3.Validate;
039import org.slf4j.Logger;
040import org.slf4j.LoggerFactory;
041import org.springframework.web.client.RestClient;
042
043import java.net.URI;
044import java.util.HashMap;
045import java.util.HashSet;
046import java.util.LinkedHashMap;
047import java.util.List;
048import java.util.Map;
049import java.util.Objects;
050import java.util.Optional;
051import java.util.Set;
052import java.util.concurrent.ConcurrentHashMap;
053import java.util.function.Function;
054import java.util.function.Predicate;
055import java.util.function.Supplier;
056import java.util.function.UnaryOperator;
057
058/**
059 * The {@code LocalhostSmileHarness} class is responsible for managing interaction
060 * with a Smile CDR environment running on the localhost. It provides utility methods to
061 * identify and interact with Smile modules, such as FHIR endpoints and HL7v2 endpoints,
062 * through exposed ports and administrative interfaces.
063 * <p>
064 * This class implements the {@link SmileHarness} interface to provide concrete
065 * implementations of the methods required to handle FHIR and administrative operations.
066 * <p>
067 * Every URL it builds is {@code protocol://host:port} followed by the target module's
068 * {@code context_path}, so modules mounted beneath a context path are reached there. The Admin JSON
069 * module's own context path comes from {@link HarnessContext#jsonAdminContextPath()}, since it is
070 * needed before any module configuration can be read; every other module's comes from that
071 * configuration. A port no module is configured with is addressed at the root.
072 * <p>
073 * This class was generated partly with the help of Claude Sonnet 3.7
074 */
075public class LocalhostSmileHarness implements SmileHarness {
076        private static final Logger ourLog = LoggerFactory.getLogger(LocalhostSmileHarness.class);
077        private static final int DEFAULT_JSON_ADMIN_PORT = 9000;
078
079        private final Function<Integer, Integer> portResolver;
080        private final HarnessContext myContext;
081        private final SmileTestHttpClient myHttpClient;
082        /**
083         * The Admin JSON port this harness talks to, resolved once so that every caller agrees on it.
084         * A context that names no port falls back to {@link #DEFAULT_JSON_ADMIN_PORT}, which is the port
085         * the connectivity check then runs against.
086         */
087        private final int myJsonAdminPort;
088        /**
089         * Filled in configuration order, which is what makes the no-argument accessors deterministic
090         * when a node has several modules of one type.
091         */
092        private final Map<String, Integer> myFhirModulePortMap = new LinkedHashMap<>();
093        private final Map<String, Integer> myEndpointHl7V2PortMap = new LinkedHashMap<>();
094        private final Map<String, Integer> mySmartEndpointPortMap = new LinkedHashMap<>();
095        private final Map<String, Integer> myPackageRegistryPortMap = new LinkedHashMap<>();
096        private final Map<String, Integer> myCdsHooksPortMap = new LinkedHashMap<>();
097        /**
098         * Every discovered port map, in the order {@link #discoveredPortFor(String)} searches them. Constant
099         * once the constructor has run, since the maps behind it are only written during discovery.
100         */
101        private final List<Map<String, Integer>> myDiscoveredPortMaps = List.of(
102                myFhirModulePortMap, myEndpointHl7V2PortMap, mySmartEndpointPortMap, myPackageRegistryPortMap, myCdsHooksPortMap);
103        /**
104         * The normalized context path of each module, keyed by the port it is configured with rather
105         * than the one it is published on. Written only in the constructor.
106         */
107        private final Map<Integer, String> myContextPathByPort = new HashMap<>();
108        /**
109         * Holds only the contexts resolved from {@link #myNodeConfigurations}, which cannot go stale
110         * because that snapshot cannot change. A module the snapshot does not cover is re-resolved per
111         * call and never lands here ? see {@link #locateFhirModule(String)}.
112         * <p>
113         * One harness is shared by every test using a given {@link ca.cdr.test.extensions.SmileCdrContainer},
114         * so this is populated on demand from more than one thread.
115         */
116        private final Map<String, FhirContext> myFhirContextByModuleId = new ConcurrentHashMap<>();
117        /**
118         * The node's module configuration as it was when this harness was built ? one
119         * {@code GET /module-config/} response, kept so that building a request costs no round trip.
120         * A module added to the node afterwards will not be in here; {@link #locateFhirModule(String)},
121         * {@link #addressOf(String)} and {@link #cachedFhirContext(String)} fall back to the node for
122         * those.
123         */
124        private final NodeConfigurations myNodeConfigurations;
125
126        private static final List<String> HL7V2_COMPATIBLE_MODULE_TYPES = List.of(
127                "ENDPOINT_HL7V2_IN_V2",
128                "ENDPOINT_HL7V2_IN"
129        );
130
131        private static final List<String> SMART_COMPATIBLE_MODULE_TYPES = List.of(
132                "SECURITY_OUT_SMART"
133        );
134
135        private static final List<String> PACKAGE_REGISTRY_COMPATIBLE_MODULE_TYPES = List.of(
136                "ENDPOINT_PACKAGE_REGISTRY"
137        );
138
139        private static final List<String> CDS_HOOKS_COMPATIBLE_MODULE_TYPES = List.of(
140                "ENDPOINT_CDS_HOOKS"
141        );
142
143        private static final String DEFAULT_FHIR_MODULE_ID = "fhir_endpoint";
144        private static final String DEFAULT_SMART_MODULE_ID = "smart_auth";
145        private static final int DEFAULT_HL7V2_PORT = 7000;
146        private static final String CONTEXT_PATH = "context_path";
147
148        /**
149         * Version markers that can appear inside a module type, checked in this order ? the first one
150         * the type contains wins.
151         */
152        private static final List<Map.Entry<String, Supplier<FhirContext>>> CONTEXT_BY_VERSION_MARKER = List.of(
153                Map.entry("R4", FhirContext::forR4Cached),
154                Map.entry("DSTU3", FhirContext::forDstu3Cached),
155                Map.entry("R5", FhirContext::forR5Cached),
156                Map.entry("DSTU2", FhirContext::forDstu2Cached)
157        );
158
159        public LocalhostSmileHarness(HarnessContext theContext) {
160                this(theContext, null);
161        }
162        public LocalhostSmileHarness(HarnessContext theContext, Map<Integer, Integer> theExposedPortMappings) {
163                this(theContext, theExposedPortMappings, SmileTestHttpClient.create());
164        }
165
166        /**
167         * Builds a harness over an existing HTTP client. The harness takes ownership: {@link #close()}
168         * releases the client, as does a failure during discovery.
169         */
170        LocalhostSmileHarness(
171                        HarnessContext theContext,
172                        Map<Integer, Integer> theExposedPortMappings,
173                        SmileTestHttpClient theHttpClient) {
174                myContext = theContext;
175                if (theExposedPortMappings == null || theExposedPortMappings.isEmpty()) {
176                        portResolver = i -> i;
177                } else {
178                        portResolver = theExposedPortMappings::get;
179                }
180                myHttpClient = theHttpClient;
181                myJsonAdminPort = resolveJsonAdminPort(theContext);
182                myContextPathByPort.put(myJsonAdminPort, theContext.jsonAdminContextPath());
183                try {
184                        myNodeConfigurations = fetchNodeConfigurations();
185                        recordContextPaths();
186                        discoverEndpoints();
187                } catch (RuntimeException e) {
188                        // Discovery talks to the running node, so it fails whenever the node is not up yet or
189                        // the ports are wrong. Without this the caller never gets a reference to close, and the
190                        // connection pool survives until the JVM exits.
191                        myHttpClient.close();
192                        throw e;
193                }
194        }
195
196        /**
197         * Records the port of every module this harness can reach, all read from
198         * {@link #myNodeConfigurations}.
199         */
200        private void discoverEndpoints() {
201                discoverEndpoints("FHIR", NodeConfigurations.ModuleConfiguration::isFhirEndpointModuleType, myFhirModulePortMap);
202                discoverEndpoints("HL7v2", moduleTypeIn(HL7V2_COMPATIBLE_MODULE_TYPES), myEndpointHl7V2PortMap);
203                discoverEndpoints("SMART", moduleTypeIn(SMART_COMPATIBLE_MODULE_TYPES), mySmartEndpointPortMap);
204                discoverEndpoints("package registry", moduleTypeIn(PACKAGE_REGISTRY_COMPATIBLE_MODULE_TYPES), myPackageRegistryPortMap);
205                discoverEndpoints("CDS Hooks", moduleTypeIn(CDS_HOOKS_COMPATIBLE_MODULE_TYPES), myCdsHooksPortMap);
206        }
207
208        /**
209         * Records the context path of every module with a port. The Admin JSON port keeps the context
210         * path the {@link HarnessContext} named, since that is the one discovery just succeeded with.
211         */
212        private void recordContextPaths() {
213                myNodeConfigurations.getNodes().stream()
214                        .flatMap(node -> node.getModules().stream())
215                        .filter(module -> StringUtils.isNotBlank(optionalConfigProperty(module, "port")))
216                        .forEach(module -> myContextPathByPort.putIfAbsent(
217                                Integer.parseInt(module.getConfigProperty("port")), contextPathOf(module)));
218        }
219
220        private static @Nullable String optionalConfigProperty(
221                        NodeConfigurations.ModuleConfiguration theModule, String theKey) {
222                // Unlike getConfigProperty(String), this tolerates a key the node reports with a null value.
223                return theModule.getConfigProperties().stream()
224                        .filter(property -> theKey.equals(property.getKey()))
225                        .map(NodeConfigurations.ModuleConfigProperty::getValue)
226                        .filter(Objects::nonNull)
227                        .findFirst()
228                        .orElse(null);
229        }
230
231        /**
232         * A module's {@code context_path}, normalized. Only {@code context_path} decides where a module's
233         * server is mounted; {@code base_url.fixed} changes the links a module writes, not where it
234         * listens, so it is not consulted.
235         */
236        private static String contextPathOf(NodeConfigurations.ModuleConfiguration theModule) {
237                return UrlPathUtil.normalizeContextPath(optionalConfigProperty(theModule, CONTEXT_PATH));
238        }
239
240        /**
241         * Records the configured port of every module {@code theIsWanted} accepts, then fails fast if
242         * the port resolver cannot map one of them.
243         *
244         * @param theKind how to describe this group of modules in the log
245         */
246        private void discoverEndpoints(
247                        String theKind,
248                        Predicate<NodeConfigurations.ModuleConfiguration> theIsWanted,
249                        Map<String, Integer> theTargetPortMap) {
250                myNodeConfigurations.getNodes().stream()
251                        .flatMap(node -> node.getModules().stream())
252                        .filter(theIsWanted)
253                        .forEach(module -> {
254                                String port = module.getConfigProperty("port");
255                                if (StringUtils.isBlank(port)) {
256                                        ourLog.warn("Found {} module, but it had no port assigned! [moduleId={}, moduleType={}]",
257                                                theKind, module.getModuleId(), module.getModuleType());
258                                        return;
259                                }
260                                ourLog.info("Found {} module. [moduleId={}, moduleType={}, port={}, contextPath={}]",
261                                        theKind, module.getModuleId(), module.getModuleType(), port, contextPathOf(module));
262                                theTargetPortMap.put(module.getModuleId(), Integer.parseInt(port));
263                        });
264
265                validatePortResolverCanResolvePorts(theTargetPortMap);
266        }
267
268        private static Predicate<NodeConfigurations.ModuleConfiguration> moduleTypeIn(List<String> theModuleTypes) {
269                return module -> theModuleTypes.contains(module.getModuleType());
270        }
271
272        private void validatePortResolverCanResolvePorts(Map<String, Integer> theModuleToPortMap) {
273                //Ensure all the endpoints we found have Mappings
274                theModuleToPortMap.forEach((key, value) -> {
275                        if (portResolver.apply(value) == null) {
276                                throw unmappedPort(key + " (" + value + ")");
277                        }
278                });
279        }
280
281        /**
282         * The one failure both discovery and {@link #resolvePort(int)} report, so that the same condition
283         * does not read two different ways depending on which of them reached it.
284         *
285         * @param theSubject what could not be mapped ? a port on its own, or a module ID and its port
286         */
287        private static IllegalStateException unmappedPort(String theSubject) {
288                return new IllegalStateException(
289                        "Could not find a mapped port for module. Please ensure your port resolver can resolve this port! : " + theSubject);
290        }
291
292        /**
293         * The Admin JSON port to use, which is the one the context names or {@link #DEFAULT_JSON_ADMIN_PORT}.
294         * Resolved once in the constructor so that discovery, {@link #getAdminJsonClient()} and
295         * {@link #adminJsonRequest(String)} cannot disagree about which port this harness talks to.
296         */
297        static int resolveJsonAdminPort(HarnessContext theContext) {
298                Integer jsonAdminPort = theContext.jsonAdminPort();
299                if (jsonAdminPort == null) {
300                        ourLog.warn("No JSON Admin port was provided in the provided harness context. Attempting to fallback to the default port [port={}]", DEFAULT_JSON_ADMIN_PORT);
301                        return DEFAULT_JSON_ADMIN_PORT;
302                }
303                ourLog.info("Found a JSON Admin port in the context provided. [port={}]", jsonAdminPort);
304                return jsonAdminPort;
305        }
306
307        /**
308         * The connectivity check and the configuration read are the same request, so this returns what
309         * it fetched rather than making the caller ask again.
310         */
311        private NodeConfigurations fetchNodeConfigurations() {
312                try {
313                        ourLog.info("Performing a connectivity check for Admin JSON module. [host={}, port={}, contextPath={}]", myContext.getContextRoot(), myJsonAdminPort, myContext.jsonAdminContextPath());
314                        NodeConfigurations retVal = getAdminJsonClient(myJsonAdminPort).getNodeConfigurations();
315                        ourLog.info("Connectivity check succeeded, client can be used! [host={}, port={}]", myContext.getContextRoot(), myJsonAdminPort);
316                        return retVal;
317                } catch(Exception e) {
318                        throw new InternalErrorException(String.format("Connectivity check failed for Admin JSON module. [host=%s, port=%d, contextPath=%s]", myContext.getContextRoot(), myJsonAdminPort, myContext.jsonAdminContextPath()), e);
319                }
320        }
321
322        @Override
323        public CdrFhirClient getSuperuserFhirClient() {
324                return getSuperuserFhirClient(firstFhirModule().getKey());
325        }
326
327        @Override
328        public CdrFhirClient getSuperuserFhirClient(int thePort) {
329                return cdrFhirClient(
330                        addressAt(thePort),
331                        () -> cachedFhirContext(fhirModuleIdForPort(thePort)),
332                        new BasicAuthInterceptor(myContext.username(), myContext.password()),
333                        this::withSuperuserAuth);
334        }
335
336        @Override
337        public AdminJsonRestClient getAdminJsonClient() {
338                return getAdminJsonClient(myJsonAdminPort);
339        }
340
341        @Override
342        public AdminJsonRestClient getAdminJsonClient(int theAdminPort) {
343                return AdminJsonRestClient.issuingOn(
344                        myHttpClient, restClientBaseUrl(addressAt(theAdminPort)), myContext.username(), myContext.password());
345        }
346
347        @Override
348        public CdrFhirClient getSuperuserFhirClient(String theModuleId) {
349                ModuleLocation module = locateFhirModule(theModuleId);
350                return cdrFhirClient(
351                        module.address(),
352                        module::fhirContext,
353                        new BasicAuthInterceptor(myContext.username(), myContext.password()),
354                        this::withSuperuserAuth);
355        }
356
357        @Override
358        public CdrFhirClient getFhirClient() {
359                return getFhirClient(firstFhirModule().getKey());
360        }
361
362        /**
363         * The FHIR endpoint used whenever a caller does not name one, so that {@link #getFhirClient()}
364         * and {@link #fhirAnonymousRequest(String)} always pick the same one. The entry carries both the
365         * module ID and its port, which callers generally need together.
366         */
367        private Map.Entry<String, Integer> firstFhirModule() {
368                return defaultModule(myFhirModulePortMap, DEFAULT_FHIR_MODULE_ID)
369                        .orElseThrow(() -> new IllegalStateException("No FHIR Endpoint found in the configuration."));
370        }
371
372        /**
373         * The module a no-argument accessor uses: the one with the conventional ID if there is one,
374         * otherwise the first of its type in the node's configuration.
375         */
376        private static Optional<Map.Entry<String, Integer>> defaultModule(
377                        Map<String, Integer> theDiscovered, @Nullable String theConventionalModuleId) {
378                if (theConventionalModuleId != null && theDiscovered.containsKey(theConventionalModuleId)) {
379                        return Optional.of(Map.entry(theConventionalModuleId, theDiscovered.get(theConventionalModuleId)));
380                }
381                return theDiscovered.entrySet().stream().findFirst();
382        }
383
384        @Override
385        public CdrFhirClient getFhirClient(int thePort) {
386                return cdrFhirClient(
387                        addressAt(thePort), () -> cachedFhirContext(fhirModuleIdForPort(thePort)), null, UnaryOperator.identity());
388        }
389
390        @Override
391        public CdrFhirClient getFhirClient(String theModuleId) {
392                ModuleLocation module = locateFhirModule(theModuleId);
393                return cdrFhirClient(module.address(), module::fhirContext, null, UnaryOperator.identity());
394        }
395
396        @Override
397        public CdrFhirClient getFhirClient(@Nonnull String theModuleId, @Nonnull String theBearerToken) {
398                Validate.notEmpty(theBearerToken, "Bearer token is required");
399                ModuleLocation module = locateFhirModule(theModuleId);
400                return cdrFhirClient(
401                        module.address(),
402                        module::fhirContext,
403                        new BearerTokenAuthInterceptor(theBearerToken),
404                        request -> request.withHeader("Authorization", "Bearer " + theBearerToken));
405        }
406
407        private CdrFhirClient cdrFhirClient(
408                        ModuleAddress theAddress,
409                        Supplier<FhirContext> theFhirContext,
410                        @Nullable IClientInterceptor theAuthInterceptor,
411                        UnaryOperator<HttpTestRequest> theAuth) {
412                String baseUrl = baseUrl(theAddress);
413                FhirContext fhirContext = theFhirContext.get();
414                IGenericClient delegate = fhirContext.newRestfulGenericClient(baseUrl);
415                if (theAuthInterceptor != null) {
416                        delegate.registerInterceptor(theAuthInterceptor);
417                }
418                return new CdrFhirClient(
419                        delegate,
420                        path -> theAuth.apply(myHttpClient.cookielessRequest(fhirContext, buildUrl(theAddress, path))),
421                        url -> theAuth.apply(myHttpClient.cookielessRequest(fhirContext, resolveServerUrl(url))));
422        }
423
424        private HttpTestRequest withSuperuserAuth(HttpTestRequest theRequest) {
425                return theRequest.withBasicAuth(myContext.username(), myContext.password());
426        }
427
428        @Override
429        public @Nonnull HttpTestRequest fhirRequest(@Nonnull String thePath) {
430                return fhirRequest(firstFhirModule().getKey(), thePath);
431        }
432
433        @Override
434        public @Nonnull HttpTestRequest fhirRequest(@Nonnull String theModuleId, @Nonnull String thePath) {
435                return buildFhirRequest(theModuleId, thePath)
436                        .withBasicAuth(myContext.username(), myContext.password());
437        }
438
439        @Override
440        public @Nonnull HttpTestRequest fhirAnonymousRequest(@Nonnull String thePath) {
441                return fhirAnonymousRequest(firstFhirModule().getKey(), thePath);
442        }
443
444        @Override
445        public @Nonnull HttpTestRequest fhirAnonymousRequest(@Nonnull String theModuleId, @Nonnull String thePath) {
446                return buildFhirAnonymousRequest(theModuleId, thePath);
447        }
448
449        @Override
450        public @Nonnull HttpTestRequest serverUrlRequest(@Nonnull String theServerUrl) {
451                return withSuperuserAuth(myHttpClient.request(resolveServerUrl(theServerUrl)));
452        }
453
454        private String resolveServerUrl(String theServerUrl) {
455                URI uri = URI.create(theServerUrl);
456                Validate.isTrue(uri.isAbsolute(), "Expected an absolute URL the server returned, got %s", theServerUrl);
457                if (uri.getPort() == -1) {
458                        return theServerUrl;
459                }
460                return myContext.getContextRoot() + ":" + resolvePort(uri.getPort()) + uri.getRawPath()
461                        + (uri.getRawQuery() == null ? "" : "?" + uri.getRawQuery());
462        }
463
464        @Override
465        public @Nonnull HttpTestRequest fhirBearerRequest(
466                        @Nonnull String theModuleId, @Nonnull String thePath, @Nonnull String theBearerToken) {
467                Validate.notEmpty(theBearerToken, "Bearer token is required");
468                return buildFhirAnonymousRequest(theModuleId, thePath).withHeader("Authorization", "Bearer " + theBearerToken);
469        }
470
471        @Override
472        public @Nonnull HttpTestRequest request(int thePort, @Nonnull String thePath) {
473                return authenticatedRequest(addressAt(thePort), thePath);
474        }
475
476        @Override
477        public @Nonnull HttpTestRequest request(@Nonnull String theModuleId, @Nonnull String thePath) {
478                return authenticatedRequest(addressOf(theModuleId), thePath);
479        }
480
481        private HttpTestRequest authenticatedRequest(ModuleAddress theAddress, String thePath) {
482                return myHttpClient.request(buildUrl(theAddress, thePath))
483                        .withBasicAuth(myContext.username(), myContext.password());
484        }
485
486        @Override
487        public @Nonnull HttpTestRequest adminJsonRequest(@Nonnull String thePath) {
488                return request(myJsonAdminPort, thePath);
489        }
490
491        @Override
492        public @Nonnull HttpTestRequest anonymousRequest(int thePort, @Nonnull String thePath) {
493                return myHttpClient.cookielessRequest(buildUrl(addressAt(thePort), thePath));
494        }
495
496        @Override
497        public @Nonnull HttpTestRequest anonymousRequest(@Nonnull String theModuleId, @Nonnull String thePath) {
498                return myHttpClient.cookielessRequest(buildUrl(addressOf(theModuleId), thePath));
499        }
500
501        @Override
502        public @Nonnull ISmileTestHttpClient getHttpClient() {
503                return myHttpClient;
504        }
505
506        @Override
507        public void clearCookies() {
508                myHttpClient.clearCookies();
509        }
510
511        @Override
512        public void close() {
513                myHttpClient.close();
514        }
515
516        /**
517         * Whether {@link #close()} has been called. {@link ca.cdr.test.extensions.SmileCdrContainer}
518         * caches a harness and uses this to replace one a caller closed, since every request through a
519         * closed harness fails on the shut-down pool.
520         */
521        public boolean isClosed() {
522                return myHttpClient.isClosed();
523        }
524
525        private HttpTestRequest buildFhirRequest(String theModuleId, String thePath) {
526                ModuleLocation module = locateFhirModule(theModuleId);
527                return myHttpClient.request(module.fhirContext(), buildUrl(module.address(), thePath));
528        }
529
530        private HttpTestRequest buildFhirAnonymousRequest(String theModuleId, String thePath) {
531                ModuleLocation module = locateFhirModule(theModuleId);
532                return myHttpClient.cookielessRequest(module.fhirContext(), buildUrl(module.address(), thePath));
533        }
534
535        /**
536         * Where a module is served: the port it is configured with, which {@link #resolvePort(int)} has
537         * yet to map, and its normalized context path.
538         */
539        private record ModuleAddress(int port, String contextPath) {}
540
541        /**
542         * A module's address together with the {@link FhirContext} its requests encode with.
543         */
544        private record ModuleLocation(ModuleAddress address, FhirContext fhirContext) {}
545
546        /**
547         * Both halves of a FHIR request builder, always resolved from one configuration ? a port read
548         * live against a FHIR version read from the stored snapshot would find a module and then fail on
549         * its version.
550         * <p>
551         * Which configuration that is, is what decides whether either half may be cached. A module
552         * {@link #myNodeConfigurations} covers is resolved from that snapshot, which cannot change, so the
553         * context is cached and nothing goes over the wire. A module added after this harness was built is
554         * resolved from one live {@code GET /module-config/} per call and <em>neither</em> half is cached:
555         * discovery's port map is written only at construction, so that module's port is re-read every
556         * time, and a version cached beside a port that is not would leave a re-versioned module encoding
557         * its body against whichever version it happened to have the first time it was asked for.
558         */
559        private ModuleLocation locateFhirModule(String theModuleId) {
560                Validate.notEmpty(theModuleId, "Module ID is required");
561                if (isInNodeConfigurations(theModuleId)) {
562                        return new ModuleLocation(
563                                addressIn(myNodeConfigurations, theModuleId), cachedSnapshotFhirContext(theModuleId));
564                }
565
566                NodeConfigurations live = getAdminJsonClient().getNodeConfigurations();
567                return new ModuleLocation(addressIn(live, theModuleId), resolveFhirContext(live, theModuleId));
568        }
569
570        /**
571         * A module's address, from the snapshot for a module it covers and otherwise from one live
572         * {@code GET /module-config/}, for the reasons given on {@link #locateFhirModule(String)}.
573         */
574        private ModuleAddress addressOf(String theModuleId) {
575                Validate.notEmpty(theModuleId, "Module ID is required");
576                NodeConfigurations nodeConfigs = isInNodeConfigurations(theModuleId)
577                        ? myNodeConfigurations
578                        : getAdminJsonClient().getNodeConfigurations();
579                return addressIn(nodeConfigs, theModuleId);
580        }
581
582        private ModuleAddress addressIn(NodeConfigurations theNodeConfigs, String theModuleId) {
583                int port = portIn(theNodeConfigs, theModuleId);
584                String contextPath = moduleIn(theNodeConfigs, theModuleId)
585                        .map(LocalhostSmileHarness::contextPathOf)
586                        .orElseGet(() -> addressAt(port).contextPath());
587                return new ModuleAddress(port, contextPath);
588        }
589
590        /**
591         * The address of whichever module the snapshot says is configured with {@code thePort}, or the
592         * root of that port when none is.
593         */
594        private ModuleAddress addressAt(int thePort) {
595                return new ModuleAddress(thePort, myContextPathByPort.getOrDefault(thePort, ""));
596        }
597
598        /**
599         * The port discovery recorded for a module, or the one the given configuration names for a module
600         * discovery did not cover.
601         */
602        private int portIn(NodeConfigurations theNodeConfigs, String theModuleId) {
603                Integer discovered = discoveredPortFor(theModuleId);
604                return discovered != null ? discovered : portFromConfiguration(theNodeConfigs, theModuleId);
605        }
606
607        /**
608         * The port discovery recorded for a module, or {@literal null} for one it did not cover.
609         */
610        private Integer discoveredPortFor(String theModuleId) {
611                for (Map<String, Integer> discovered : myDiscoveredPortMaps) {
612                        Integer port = discovered.get(theModuleId);
613                        if (port != null) {
614                                return port;
615                        }
616                }
617                return null;
618        }
619
620        /**
621         * The port a module is configured with, read from a configuration the caller already holds ? the
622         * same {@code port} property discovery reads, so a module resolved here and one resolved at
623         * construction agree.
624         */
625        private static int portFromConfiguration(NodeConfigurations theNodeConfigs, String theModuleId) {
626                NodeConfigurations.ModuleConfiguration module = moduleIn(theNodeConfigs, theModuleId)
627                        .orElseThrow(() -> new IllegalArgumentException(
628                                "Module with ID " + theModuleId + " not found in node configuration"));
629
630                String port = module.getConfigProperty("port");
631                if (StringUtils.isBlank(port)) {
632                        throw new IllegalArgumentException("Module has no port configured. [moduleId=%s, moduleType=%s]"
633                                .formatted(theModuleId, module.getModuleType()));
634                }
635                return Integer.parseInt(port);
636        }
637
638        private static Optional<NodeConfigurations.ModuleConfiguration> moduleIn(
639                        NodeConfigurations theNodeConfigs, String theModuleId) {
640                return theNodeConfigs.getNodes().stream()
641                        .map(node -> node.getModule(theModuleId))
642                        .flatMap(Optional::stream)
643                        .findFirst();
644        }
645
646        /**
647         * The ID of the FHIR endpoint module configured with the given port, falling back to the node
648         * for a port discovery did not cover.
649         */
650        private String fhirModuleIdForPort(int thePort) {
651                return myFhirModulePortMap.entrySet().stream()
652                        .filter(entry -> entry.getValue() == thePort)
653                        .map(Map.Entry::getKey)
654                        .findFirst()
655                        .orElseGet(() -> getAdminJsonClient().getFhirEndpointModuleIdFromPort(thePort));
656        }
657
658        /**
659         * The FHIR context of a module {@link #myNodeConfigurations} covers, read once from that snapshot
660         * and then cached ? the snapshot cannot change, so neither can the answer.
661         * {@link #getFhirContext(String)} re-reads the node's configuration on every call, which a
662         * request builder must not do: that would cost a round trip per assertion and would make an
663         * anonymous request depend on the Admin JSON credentials.
664         * <p>
665         * A module added after this harness was built falls back to that live lookup, and its answer is
666         * deliberately <em>not</em> cached, for the reason given on {@link #locateFhirModule(String)}.
667         */
668        private FhirContext cachedFhirContext(String theModuleId) {
669                return isInNodeConfigurations(theModuleId)
670                        ? cachedSnapshotFhirContext(theModuleId)
671                        : getFhirContext(theModuleId);
672        }
673
674        /**
675         * The cached half of {@link #cachedFhirContext(String)}, for a caller that has already established
676         * that {@link #myNodeConfigurations} covers the module.
677         */
678        private FhirContext cachedSnapshotFhirContext(String theModuleId) {
679                FhirContext cached = myFhirContextByModuleId.get(theModuleId);
680                if (cached != null) {
681                        return cached;
682                }
683                return cacheFhirContext(theModuleId, resolveFhirContext(myNodeConfigurations, theModuleId));
684        }
685
686        /**
687         * Publishes a resolved context, keeping whichever value won if another thread resolved the same
688         * module first. A duplicate resolve under a race costs nothing ? the work is a string match over
689         * a configuration already in memory, and {@link FhirContext#forR4Cached()} and its siblings are
690         * themselves cached.
691         */
692        private FhirContext cacheFhirContext(String theModuleId, FhirContext theResolved) {
693                FhirContext raced = myFhirContextByModuleId.putIfAbsent(theModuleId, theResolved);
694                return raced != null ? raced : theResolved;
695        }
696
697        private boolean isInNodeConfigurations(String theModuleId) {
698                return moduleIn(myNodeConfigurations, theModuleId).isPresent();
699        }
700
701        private String buildUrl(ModuleAddress theAddress, String thePath) {
702                return baseUrl(theAddress) + requirePath(thePath);
703        }
704
705        /**
706         * A module's base URL, such as {@code http://localhost:8000/fhir-request}, with no trailing
707         * slash. Every URL this harness builds starts here.
708         */
709        private String baseUrl(ModuleAddress theAddress) {
710                return myContext.getContextRoot() + ":" + resolvePort(theAddress.port()) + theAddress.contextPath();
711        }
712
713        private String restClientBaseUrl(ModuleAddress theAddress) {
714                return UrlPathUtil.withTrailingSlash(baseUrl(theAddress));
715        }
716
717        /**
718         * Guards every request builder against being handed a module ID where it wants a path ? the two
719         * single-{@code String} request builders take a path, every other single-{@code String} method on
720         * {@link SmileHarness} takes a module ID, and {@code fhirRequest("my_endpoint")} would otherwise
721         * issue against the default module and report a 404 from a module the caller never named.
722         * <p>
723         * An empty path is allowed: it addresses the module's base URL, where a transaction bundle is
724         * posted.
725         */
726        private static String requirePath(String thePath) {
727                if (!thePath.isEmpty() && !thePath.startsWith("/")) {
728                        throw new IllegalArgumentException(
729                                "Path must begin with a slash; a module ID belongs in the two-argument overload. [path=%s]"
730                                        .formatted(thePath));
731                }
732                return thePath;
733        }
734
735        /**
736         * The port a module configured with {@code thePort} is actually published on. Every URL this
737         * harness builds goes through here, so that an unresolvable port fails with the port that could
738         * not be mapped rather than with a URL containing the literal {@literal null}.
739         */
740        private int resolvePort(int thePort) {
741                Integer resolvedPort = portResolver.apply(thePort);
742                if (resolvedPort == null) {
743                        throw unmappedPort(String.valueOf(thePort));
744                }
745                return resolvedPort;
746        }
747
748        /**
749         * Gets the only persistence module's FhirContext.
750         * If More or less than 1 persistence modules are present, throws {@link IllegalStateException}.}
751         * <p>
752         * See {@link #getFhirContext(String)} if you have multiple persistence modules.
753         *
754         * @return the {@link FhirContext} of the only persistence module.}
755         */
756        @Override
757        public FhirContext getFhirContext() {
758                NodeConfigurations nodeConfigs = getAdminJsonClient().getNodeConfigurations();
759                Set<String> persistenceModuleIds = new HashSet<>();
760
761                for (NodeConfigurations.NodeConfiguration node : nodeConfigs.getNodes()) {
762                        for (NodeConfigurations.ModuleConfiguration module : node.getModules()) {
763                                String moduleType = module.getModuleType();
764                                if (moduleType != null && moduleType.startsWith("PERSISTENCE_")) {
765                                        persistenceModuleIds.add(module.getModuleId());
766                                }
767                        }
768                }
769
770                if (persistenceModuleIds.size() > 1) {
771                        throw new IllegalStateException("Multiple different persistence modules found in the configuration, please specify. " + String.join(", ", persistenceModuleIds));
772                } else {
773                        return persistenceModuleIds.stream().map(this::getFhirContext).findFirst().orElseThrow(() -> new IllegalStateException("No persistence module found in the configuration."));
774                }
775        }
776
777        /**
778         * Get the FHIR context for a specific persistence/fhir endpoint  module.
779         * @param moduleId The ID of the persistence or FHIR endpoint module to get the FHIR context for
780         * @return The FHIR context for the specified module
781         * @throws IllegalStateException if the module is not found or has an unsupported type
782         */
783        @Override
784        public FhirContext getFhirContext(String moduleId) {
785                return resolveFhirContext(getAdminJsonClient().getNodeConfigurations(), moduleId);
786        }
787
788        private FhirContext resolveFhirContext(NodeConfigurations theNodeConfigs, String theModuleId) {
789                Validate.notEmpty(theModuleId, "Module ID is required");
790
791                NodeConfigurations.ModuleConfiguration module = moduleIn(theNodeConfigs, theModuleId)
792                        .orElseThrow(() -> new IllegalArgumentException("No module found. [moduleId=%s]".formatted(theModuleId)));
793
794                return fhirContextOf(theNodeConfigs, module)
795                        .orElseThrow(() -> new IllegalArgumentException(
796                                "Could not retrieve a FhirContext from the provided module. [moduleId=%s, module_type=%s]"
797                                        .formatted(theModuleId, module.getModuleType())));
798        }
799
800        /**
801         * A module's FHIR version comes from one of two places: a version marker in its own type
802         * ({@code ENDPOINT_FHIR_REST_R4}), or ? for the types that carry no version ? whatever the
803         * module points at. Empty when the type offers neither.
804         */
805        private Optional<FhirContext> fhirContextOf(
806                        NodeConfigurations theNodeConfigs, NodeConfigurations.ModuleConfiguration theModule) {
807                String moduleType = theModule.getModuleType();
808                if (moduleType == null) {
809                        return Optional.empty();
810                }
811                return fhirContextFromVersionMarker(moduleType)
812                        .or(() -> fhirContextFromDelegatingType(theNodeConfigs, theModule, moduleType));
813        }
814
815        private static Optional<FhirContext> fhirContextFromVersionMarker(String theModuleType) {
816                return CONTEXT_BY_VERSION_MARKER.stream()
817                        .filter(marker -> theModuleType.contains(marker.getKey()))
818                        .findFirst()
819                        .map(marker -> marker.getValue().get());
820        }
821
822        /**
823         * The types that name no version of their own: the FHIR endpoint takes its version from the
824         * persistence module it depends on, and the other two read it from their configuration.
825         */
826        private Optional<FhirContext> fhirContextFromDelegatingType(
827                        NodeConfigurations theNodeConfigs,
828                        NodeConfigurations.ModuleConfiguration theModule,
829                        String theModuleType) {
830                return switch (theModuleType) {
831                        case "ENDPOINT_FHIR_REST" -> persistenceModuleIdOf(theModule)
832                                .map(targetModuleId -> resolveFhirContext(theNodeConfigs, targetModuleId));
833                        case "ENDPOINT_FHIR_GATEWAY" ->
834                                Optional.of(fhirContextForVersion(theModule.getConfigProperty("fhir_version")));
835                        case "ENDPOINT_HYBRID_PROVIDERS" ->
836                                Optional.of(fhirContextForVersion(theModule.getConfigProperty("definitions.fhir_version")));
837                        default -> Optional.empty();
838                };
839        }
840
841        private static Optional<String> persistenceModuleIdOf(NodeConfigurations.ModuleConfiguration theModule) {
842                return theModule.getDependencies().stream()
843                        .filter(dependency -> dependency.getDependencyType().startsWith("PERSISTENCE_"))
844                        .map(NodeConfigurations.ModuleDependency::getTargetModuleId)
845                        .findFirst();
846        }
847
848        private static FhirContext fhirContextForVersion(String theFhirVersion) {
849                return FhirContext.forCached(FhirVersionEnum.forVersionString(theFhirVersion));
850        }
851
852        @Override
853        public HL7V2RestClient getHL7V2RestClient() {
854                Optional<Map.Entry<String, Integer>> discovered = defaultModule(myEndpointHl7V2PortMap, null);
855                if (discovered.isPresent()) {
856                        return getHL7V2RestClient(discovered.get().getKey());
857                }
858                ourLog.warn("No HL7v2 endpoint found in configuration, using default port {}", DEFAULT_HL7V2_PORT);
859                return getHL7V2RestClient(DEFAULT_HL7V2_PORT);
860        }
861
862        @Override
863        public HL7V2RestClient getHL7V2RestClient(int thePort) {
864                return hl7V2RestClientAt(addressAt(thePort));
865        }
866
867        @Override
868        public HL7V2RestClient getHL7V2RestClient(String theModuleId) {
869                return hl7V2RestClientAt(addressOf(theModuleId));
870        }
871
872        private HL7V2RestClient hl7V2RestClientAt(ModuleAddress theAddress) {
873                // The HL7v2 client posts to its base URL itself, so it takes that URL without a trailing slash.
874                return HL7V2RestClient.issuingOn(myHttpClient, baseUrl(theAddress), myContext.username(), myContext.password());
875        }
876
877        @Override
878        public OutboundSmartClient getOutboundSmartClient() {
879                return defaultModule(mySmartEndpointPortMap, DEFAULT_SMART_MODULE_ID)
880                        .map(module -> getOutboundSmartClient(module.getKey()))
881                        .orElseThrow(() -> new IllegalStateException("No SMART endpoint found in the configuration for OutboundSmartClient."));
882        }
883
884        @Override
885        public OutboundSmartClient getOutboundSmartClient(int thePort) {
886                return outboundSmartClientAt(addressAt(thePort));
887        }
888
889        @Override
890        public OutboundSmartClient getOutboundSmartClient(String theModuleId) {
891                return outboundSmartClientAt(addressOf(theModuleId));
892        }
893
894        private OutboundSmartClient outboundSmartClientAt(ModuleAddress theAddress) {
895                String baseUrl = baseUrl(theAddress);
896                RestClient restClient = RestClient.builder()
897                        .requestFactory(RequestFactoryUtil.wrap(myHttpClient))
898                        .baseUrl(UrlPathUtil.withTrailingSlash(baseUrl))
899                        .build();
900                return new OutboundSmartClient(restClient, baseUrl);
901        }
902
903        @Override
904        public NpmPackageClient getNpmPackageClient() {
905                // Look for the first available Package Registry module
906                Optional<Map.Entry<String, Integer>> discovered = defaultModule(myPackageRegistryPortMap, null);
907                if (discovered.isPresent()) {
908                        return getNpmPackageClient(discovered.get().getKey());
909                } else {
910                        // Fall back to default port 8002 if no modules discovered
911                        ourLog.warn("No Package Registry endpoint found in configuration, using default port 8002");
912                        return getNpmPackageClient(8002);
913                }
914        }
915
916        @Override
917        public NpmPackageClient getNpmPackageClient(int thePort) {
918                return npmPackageClientAt(addressAt(thePort));
919        }
920
921        @Override
922        public NpmPackageClient getNpmPackageClient(String theModuleId) {
923                return npmPackageClientAt(addressOf(theModuleId));
924        }
925
926        @Override
927        public @Nonnull SmartHealthLinkClient getSmartHealthLinkClient(@Nonnull String theModuleId) {
928                return SmartHealthLinkClient.issuingOn(
929                        myHttpClient, restClientBaseUrl(addressOf(theModuleId)), myContext.username(), myContext.password());
930        }
931
932        @Override
933        public @Nonnull SmilePortalClient getSmilePortalClient(@Nonnull String theModuleId) {
934                return SmilePortalClient.issuingOn(
935                        myHttpClient, restClientBaseUrl(addressOf(theModuleId)), myContext.username(), myContext.password());
936        }
937
938        private NpmPackageClient npmPackageClientAt(ModuleAddress theAddress) {
939                return NpmPackageClient.issuingOn(
940                        myHttpClient, restClientBaseUrl(theAddress), myContext.username(), myContext.password());
941        }
942
943        @Override
944        public @Nonnull CdsHooksClient getCdsHooksClient() {
945                return defaultModule(myCdsHooksPortMap, null)
946                        .map(module -> getCdsHooksClient(module.getKey()))
947                        .orElseThrow(() -> new IllegalStateException("No CDS Hooks endpoint found in the configuration."));
948        }
949
950        @Override
951        public @Nonnull CdsHooksClient getCdsHooksClient(int thePort) {
952                return cdsHooksClientAt(addressAt(thePort));
953        }
954
955        @Override
956        public @Nonnull CdsHooksClient getCdsHooksClient(@Nonnull String theModuleId) {
957                return cdsHooksClientAt(addressOf(theModuleId));
958        }
959
960        private CdsHooksClient cdsHooksClientAt(ModuleAddress theAddress) {
961                return CdsHooksClient.issuingOn(myHttpClient, baseUrl(theAddress), myContext.username(), myContext.password());
962        }
963}