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}