001package ca.cdr.test.app.clients; 002/*- 003 * #%L 004 * Smile CDR - CDR 005 * %% 006 * Copyright (C) 2016 - 2026 Smile CDR, Inc. 007 * %% 008 * All rights reserved. 009 * #L% 010 */ 011 012import ca.cdr.api.model.enm.ClientStatusFilterEnum; 013import ca.cdr.api.model.json.AuditEventsJson; 014import ca.cdr.api.model.json.CdaJson.CdaTemplateJson; 015import ca.cdr.api.model.json.CdaJson.CdaTemplateRequestJson; 016import ca.cdr.api.model.json.CdaJson.CdaTemplateResponseJson; 017import ca.cdr.api.model.json.CdaJson.CdaTemplateViewAllResponseJson; 018import ca.cdr.api.model.json.EtlImportProcessFileResponseJson; 019import ca.cdr.api.model.json.LoggerConfigJson; 020import ca.cdr.api.model.json.MdmAlgorithmsListJson; 021import ca.cdr.api.model.json.MdmMetricsJson; 022import ca.cdr.api.model.json.OAuth2ClientDetailsJson; 023import ca.cdr.api.model.json.OAuth2ClientsJson; 024import ca.cdr.api.model.json.OAuth2KeystoreJson; 025import ca.cdr.api.model.json.OAuth2KeystoresJson; 026import ca.cdr.api.model.json.OAuth2RevokeAllWithScopeResponseJson; 027import ca.cdr.api.model.json.OAuth2ServerJson; 028import ca.cdr.api.model.json.OAuth2ServersJson; 029import ca.cdr.api.model.json.OAuth2WritableClientDetailsJson; 030import ca.cdr.api.model.json.PrivacyNoticeAcceptRequestJson; 031import ca.cdr.api.model.json.PrivacyNoticeAcceptanceJson; 032import ca.cdr.api.model.json.PrivacyNoticeJson; 033import ca.cdr.api.model.json.PrivacyNoticeStatusJson; 034import ca.cdr.api.model.json.TransactionLogEventCodesJson; 035import ca.cdr.api.model.json.TransactionLogEventsJson; 036import ca.cdr.api.model.json.UserDetailsJson; 037import ca.cdr.api.model.json.batch2json.AllBatch2JobInstancesJson; 038import ca.cdr.api.model.json.batch2json.Batch2JobInstanceJson; 039import ca.cdr.api.model.json.batch2json.Batch2JobInstancesJson; 040import ca.cdr.test.app.clients.common.HttpClientOwnership; 041import ca.cdr.test.app.clients.common.RequestFactoryUtil; 042import ca.cdr.test.app.clients.common.SmileTestHttpClient; 043import ca.cdr.test.model.NodeConfigurations; 044import ca.uhn.fhir.context.FhirContext; 045import ca.uhn.fhir.context.FhirVersionEnum; 046import com.fasterxml.jackson.core.JsonProcessingException; 047import com.fasterxml.jackson.databind.JsonNode; 048import com.fasterxml.jackson.databind.ObjectMapper; 049import jakarta.annotation.Nonnull; 050import jakarta.annotation.Nullable; 051import org.apache.commons.lang3.Validate; 052import org.hl7.fhir.instance.model.api.IBaseParameters; 053import org.hl7.fhir.instance.model.api.IBaseResource; 054import org.slf4j.Logger; 055import org.slf4j.LoggerFactory; 056import org.springframework.core.ParameterizedTypeReference; 057import org.springframework.http.MediaType; 058import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; 059import org.springframework.http.client.support.BasicAuthenticationInterceptor; 060import org.springframework.http.converter.ByteArrayHttpMessageConverter; 061import org.springframework.http.converter.HttpMessageConverter; 062import org.springframework.http.converter.ResourceHttpMessageConverter; 063import org.springframework.http.converter.StringHttpMessageConverter; 064import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; 065import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; 066import org.springframework.util.LinkedMultiValueMap; 067import org.springframework.util.MultiValueMap; 068import org.springframework.web.client.HttpClientErrorException; 069import org.springframework.web.client.RestClient; 070import org.springframework.web.client.RestClientException; 071 072import java.time.Duration; 073import java.util.ArrayList; 074import java.util.List; 075import java.util.Map; 076import java.util.Objects; 077import java.util.Optional; 078import java.util.Set; 079 080import static org.testcontainers.shaded.org.awaitility.Awaitility.await; 081 082// Many new methods generated by Claude Sonnet 3.7 083 084/** 085 * AdminJsonRestClient is a client for the Admin API of the CDR. 086 * It currently supports anonymous and http-basic authentication. 087 */ 088public class AdminJsonRestClient implements AutoCloseable { 089 private static final Logger ourLog = LoggerFactory.getLogger(AdminJsonRestClient.class); 090 091 private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(90); 092 /** 093 * Package-private and non-final so tests can inject a smaller poll interval to exercise multi-iteration 094 * loop semantics without introducing a public setter on the production API. 095 */ 096 static Duration POLL_INTERVAL = Duration.ofMillis(500); 097 private static final String TARGET_STATUS = "STARTED"; 098 private static final String FAILED_TO_START = "FAILED_TO_START"; 099 private static final String FAILED_TO_STOP = "FAILED_TO_STOP"; 100 private static final String NOT_PRESENT = "<NOT_PRESENT>"; 101 private static final String NO_PROCESSES = "<NO_PROCESSES>"; 102 private static final String NEVER_OBSERVED = "<NEVER_OBSERVED>"; 103 104 final RestClient myRestClient; 105 106 /** 107 * Package-private hook so tests can deterministically observe and control the poll-loop sleep 108 * (e.g. signal a latch when the worker enters the sleep branch, then perform a real interruptible 109 * {@link Thread#sleep(long)}). Production code uses {@link Thread#sleep(long)} directly. 110 */ 111 @FunctionalInterface 112 interface InterruptibleSleeper { 113 void sleep(long theMillis) throws InterruptedException; 114 } 115 116 InterruptibleSleeper mySleeper = Thread::sleep; 117 118 /** 119 * The client this one issues on, and whether closing this client should release it. 120 * {@literal null} for a client built from a {@link RestClient} directly, which brought no 121 * connection pool of its own. 122 */ 123 private final HttpClientOwnership myOwnership; 124 125 AdminJsonRestClient(RestClient theRestClient) { 126 this(theRestClient, null); 127 } 128 129 private AdminJsonRestClient(RestClient theRestClient, @Nullable HttpClientOwnership theOwnership) { 130 myOwnership = theOwnership; 131 myRestClient = theRestClient; 132 } 133 134 /** 135 * Opens a client authenticating as the given user, over a connection pool of its own. 136 * <p> 137 * The caller owns the pool: open this in a try-with-resources block, or close it from an 138 * {@code @AfterAll} when it is a field. Use 139 * {@link #issuingOn(SmileTestHttpClient, String, String, String)} instead where a client to 140 * issue on already exists. 141 * 142 * @param theBaseUrl the Admin JSON base URL, including port 143 * @param theUsername the user to authenticate as 144 * @param thePassword that user's password 145 */ 146 public static @Nonnull AdminJsonRestClient open( 147 @Nonnull String theBaseUrl, @Nonnull String theUsername, @Nonnull String thePassword) { 148 HttpClientOwnership ownership = HttpClientOwnership.resolve(null); 149 150 return authenticatedClient(ownership, theBaseUrl, theUsername, thePassword); 151 } 152 153 /** 154 * Opens a client sending no credentials, over a connection pool of its own. 155 * <p> 156 * The caller owns the pool ? see {@link #open(String, String, String)}. 157 * 158 * @param theBaseUrl the Admin JSON base URL, including port 159 */ 160 public static @Nonnull AdminJsonRestClient openAnonymous(@Nonnull String theBaseUrl) { 161 HttpClientOwnership ownership = HttpClientOwnership.resolve(null); 162 163 return new AdminJsonRestClient( 164 builderForUrlWithJsonDefault(theBaseUrl, ownership.client()).build(), ownership); 165 } 166 167 /** 168 * Builds a client that issues its requests on {@code theHttpClient}, sharing that client's 169 * connection pool and cookie store with everything else built over it. A harness passes its own 170 * client here so that a session established through this client is visible to the harness's 171 * other clients, and so that {@code clearCookies()} reaches this client too. 172 * <p> 173 * The pool stays the caller's: {@link #close()} leaves it open, so there is nothing here for the 174 * caller to release. Use {@link #open(String, String, String)} to build a pool of your own. 175 * 176 * @param theHttpClient the client to issue on, which the caller keeps ownership of 177 * @param theBaseUrl the Admin JSON base URL, including port 178 * @param theUsername the user to authenticate as 179 * @param thePassword that user's password 180 */ 181 public static @Nonnull AdminJsonRestClient issuingOn( 182 @Nonnull SmileTestHttpClient theHttpClient, 183 @Nonnull String theBaseUrl, 184 @Nonnull String theUsername, 185 @Nonnull String thePassword) { 186 return authenticatedClient( 187 HttpClientOwnership.resolve(theHttpClient), theBaseUrl, theUsername, thePassword); 188 } 189 190 private static @Nonnull AdminJsonRestClient authenticatedClient( 191 @Nonnull HttpClientOwnership theOwnership, 192 @Nonnull String theBaseUrl, 193 @Nonnull String theUsername, 194 @Nonnull String thePassword) { 195 RestClient restClient = builderForUrlWithJsonDefault(theBaseUrl, theOwnership.client()) 196 .requestInterceptor(new BasicAuthenticationInterceptor(theUsername, thePassword)) 197 .build(); 198 199 return new AdminJsonRestClient(restClient, theOwnership); 200 } 201 202 /** 203 * @deprecated Use {@link #open(String, String, String)}, whose name says that the client owns the 204 * connection pool it returns and that the caller has to close it. 205 */ 206 @Deprecated(since = "2026.11.R01", forRemoval = true) 207 public static @Nonnull AdminJsonRestClient build( 208 @Nonnull String theBaseUrl, @Nonnull String theUsername, @Nonnull String thePassword) { 209 return open(theBaseUrl, theUsername, thePassword); 210 } 211 212 /** 213 * @deprecated Use {@link #openAnonymous(String)}, whose name says that the client owns the 214 * connection pool it returns and that the caller has to close it. 215 */ 216 @Deprecated(since = "2026.11.R01", forRemoval = true) 217 public static @Nonnull AdminJsonRestClient buildAnonymous(@Nonnull String theBaseUrl) { 218 return openAnonymous(theBaseUrl); 219 } 220 221 private static @Nonnull RestClient.Builder builderForUrlWithJsonDefault( 222 String theBaseUrl, @Nonnull SmileTestHttpClient theHttpClient) { 223 HttpComponentsClientHttpRequestFactory requestFactory = RequestFactoryUtil.wrap(theHttpClient); 224 225 List<HttpMessageConverter<?>> messageConverters = new ArrayList<>(); 226 messageConverters.add(new ByteArrayHttpMessageConverter()); 227 messageConverters.add(new StringHttpMessageConverter()); 228 messageConverters.add(new ResourceHttpMessageConverter(false)); 229 messageConverters.add(new AllEncompassingFormHttpMessageConverter()); 230 messageConverters.add(new MappingJackson2HttpMessageConverter()); 231 232 return RestClient.builder() 233 .baseUrl(theBaseUrl) 234 .requestFactory(requestFactory) 235 .messageConverters(messageConverters) 236 // This sets the default content type to JSON, but allows the actual request to override it. 237 .defaultRequest(r -> r.accept(MediaType.APPLICATION_JSON)); 238 } 239 240 241 242 @Nonnull 243 public UserDetailsJson userCreate(String theNode, String theModuleId, UserDetailsJson userDetails) { 244 Validate.notEmpty(theNode, "Node ID is required"); 245 Validate.notEmpty(theModuleId, "Module ID is required"); 246 Validate.notNull(userDetails, "User does not have an assigned pid. Use userCreate() to create users."); 247 UserDetailsJson result = myRestClient 248 .post() 249 .uri("user-management/{nodeId}/{moduleId}", theNode, theModuleId) 250 .body(userDetails) 251 .retrieve() 252 .body(UserDetailsJson.class); 253 return Objects.requireNonNull(result); 254 } 255 256 @Nonnull 257 public UserDetailsJson userUpdate(UserDetailsJson theUserDetails) { 258 Validate.notEmpty(theUserDetails.getNodeId(), "Node ID is required"); 259 Validate.notEmpty(theUserDetails.getModuleId(), "Module ID is required"); 260 Validate.notNull( 261 theUserDetails.getPid(), "User does not have an assigned pid. Use userCreate() to create users."); 262 UserDetailsJson result = myRestClient 263 .put() 264 .uri( 265 "user-management/{nodeId}/{moduleId}/{userId}", 266 theUserDetails.getNodeId(), 267 theUserDetails.getModuleId(), 268 theUserDetails.getPid()) 269 .body(theUserDetails) 270 .retrieve() 271 .body(UserDetailsJson.class); 272 return Objects.requireNonNull(result); 273 } 274 275 @Nonnull 276 public JsonNode userFindByUsername(String theNodeId, String theModuleId, String theUsername) { 277 Validate.notEmpty(theNodeId, "Node ID is required"); 278 Validate.notEmpty(theModuleId, "Module ID is required"); 279 Validate.notEmpty(theUsername, "username is required"); 280 JsonNode result = myRestClient 281 .get() 282 .uri("user-management/{nodeId}/{moduleId}?searchTerm={username}", theNodeId, theModuleId, theUsername) 283 .retrieve() 284 .body(JsonNode.class); 285 return Objects.requireNonNull(result); 286 } 287 288 /** 289 * Find users by username and return the result as a String that can be used with ObjectMapper 290 * 291 * @param theNodeId The node ID 292 * @param theModuleId The module ID 293 * @param theUsername The username to search for 294 * @return The JSON string representation of the result 295 */ 296 @Nonnull 297 public String userFindByUsernameAsString(String theNodeId, String theModuleId, String theUsername) { 298 JsonNode result = userFindByUsername(theNodeId, theModuleId, theUsername); 299 return result.toString(); 300 } 301 302 @Nonnull 303 public JsonNode userFindAll(String theNodeId, String theModuleId, int thePageSize) { 304 Validate.notEmpty(theNodeId, "Node ID is required"); 305 Validate.notEmpty(theModuleId, "Module ID is required"); 306 JsonNode result = myRestClient 307 .get() 308 .uri("user-management/{nodeId}/{moduleId}?pageSize={pageSize}", theNodeId, theModuleId, thePageSize) 309 .retrieve() 310 .body(JsonNode.class); 311 return Objects.requireNonNull(result); 312 } 313 314 /** 315 * Find all users and return the result as a String that can be used with ObjectMapper 316 * 317 * @param theNodeId The node ID 318 * @param theModuleId The module ID 319 * @param thePageSize The page size 320 * @return The JSON string representation of the result 321 */ 322 @Nonnull 323 public String userFindAllAsString(String theNodeId, String theModuleId, int thePageSize) { 324 JsonNode result = userFindAll(theNodeId, theModuleId, thePageSize); 325 return result.toString(); 326 } 327 328 @Nonnull 329 public UserDetailsJson userFetchByPid(String theNodeId, String theModuleId, Long thePid) { 330 Validate.notEmpty(theNodeId, "Node ID is required"); 331 Validate.notEmpty(theModuleId, "Module ID is required"); 332 Validate.notNull(thePid, "User does not have an assigned pid. Use userCreate() to create users."); 333 UserDetailsJson result = myRestClient 334 .get() 335 .uri("user-management/{nodeId}/{moduleId}/{userId}", theNodeId, theModuleId, thePid) 336 .retrieve() 337 .body(UserDetailsJson.class); 338 return Objects.requireNonNull(result); 339 } 340 341 /** 342 * Get the module configuration for a specific node and module 343 */ 344 @Nonnull 345 public JsonNode getModuleConfig(String theNodeId, String theModuleId) { 346 Validate.notEmpty(theNodeId, "Node ID is required"); 347 Validate.notEmpty(theModuleId, "Module ID is required"); 348 JsonNode result = myRestClient 349 .get() 350 .uri("module-config/{nodeId}/{moduleId}", theNodeId, theModuleId) 351 .retrieve() 352 .body(JsonNode.class); 353 return Objects.requireNonNull(result); 354 } 355 356 public JsonNode getModuleInterceptors(String theNodeId, String theModuleId) { 357 Validate.notEmpty(theNodeId, "Node ID is required"); 358 Validate.notEmpty(theModuleId, "Module ID is required"); 359 JsonNode result = myRestClient 360 .get() 361 .uri("module-config/{nodeId}/{moduleId}/interceptors", theNodeId, theModuleId) 362 .retrieve() 363 .body(JsonNode.class); 364 return Objects.requireNonNull(result); 365 } 366 367 /** 368 * Update the configuration for a specific module. When either {@code theRestart} or {@code theReload} is set, 369 * the client blocks until the module reports {@value #TARGET_STATUS} (up to {@link #DEFAULT_TIMEOUT}). 370 * Use {@link #updateModuleConfig(String, String, JsonNode, boolean, boolean, boolean)} to opt out of the wait. 371 */ 372 @Nonnull 373 public JsonNode updateModuleConfig( 374 String theNodeId, 375 String theModuleId, 376 JsonNode theOptions, 377 boolean theRestart, 378 boolean theReload) { 379 return updateModuleConfig(theNodeId, theModuleId, theOptions, theRestart, theReload, true, DEFAULT_TIMEOUT); 380 } 381 382 /** 383 * Update the configuration for a specific module. 384 * 385 * @param theShouldWaitForRestart when {@code true} and either {@code theRestart} or {@code theReload} is set, 386 * the client polls the admin status endpoint until the module reports 387 * {@value #TARGET_STATUS}, up to {@link #DEFAULT_TIMEOUT}. 388 */ 389 @Nonnull 390 public JsonNode updateModuleConfig( 391 String theNodeId, 392 String theModuleId, 393 JsonNode theOptions, 394 boolean theRestart, 395 boolean theReload, 396 boolean theShouldWaitForRestart) { 397 return updateModuleConfig(theNodeId, theModuleId, theOptions, theRestart, theReload, theShouldWaitForRestart, DEFAULT_TIMEOUT); 398 } 399 400 /** 401 * Update the configuration for a specific module with a caller-supplied wait timeout. 402 * 403 * @param theShouldWaitForRestart when {@code true} and either {@code theRestart} or {@code theReload} is set, 404 * the client polls the admin status endpoint until the module reports 405 * {@value #TARGET_STATUS}, up to {@code theWaitTimeout}. 406 * @param theWaitTimeout the maximum duration to wait for the module to report {@value #TARGET_STATUS} 407 * when {@code theShouldWaitForRestart} is {@code true}. Ignored otherwise. 408 */ 409 @Nonnull 410 public JsonNode updateModuleConfig( 411 String theNodeId, 412 String theModuleId, 413 JsonNode theOptions, 414 boolean theRestart, 415 boolean theReload, 416 boolean theShouldWaitForRestart, 417 Duration theWaitTimeout) { 418 Validate.notEmpty(theNodeId, "Node ID is required"); 419 Validate.notEmpty(theModuleId, "Module ID is required"); 420 Validate.notNull(theOptions, "Options is required"); 421 422 JsonNode result = 423 myRestClient.put().uri("module-config/{nodeId}/{theModuleId}/set?restart={theRestart}&reload={theReload}", theNodeId, theModuleId, theRestart, theReload).body(theOptions).retrieve().body(JsonNode.class); 424 425 if (theShouldWaitForRestart && (theRestart || theReload)) { 426 Validate.notNull(theWaitTimeout, "Wait timeout is required"); 427 waitForModuleStarted(theNodeId, theModuleId, theWaitTimeout); 428 } 429 430 return Objects.requireNonNull(result); 431 } 432 433 /** 434 * Request to start a module on all processes 435 */ 436 @Nonnull 437 private JsonNode startModule(String theNodeId, String theModuleId) { 438 Validate.notEmpty(theNodeId, "Node ID is required"); 439 Validate.notEmpty(theModuleId, "Module ID is required"); 440 JsonNode result = myRestClient 441 .post() 442 .uri("module-config/{nodeId}/{moduleId}/start", theNodeId, theModuleId) 443 .retrieve() 444 .body(JsonNode.class); 445 return Objects.requireNonNull(result); 446 } 447 448 /** 449 * Request to stop a module on all processes 450 * ModuleProcessesStatusChangeResponseJson 451 */ 452 @Nonnull 453 public JsonNode stopModule(String theNodeId, String theModuleId) { 454 Validate.notEmpty(theNodeId, "Node ID is required"); 455 Validate.notEmpty(theModuleId, "Module ID is required"); 456 JsonNode result = myRestClient 457 .post() 458 .uri("module-config/{nodeId}/{moduleId}/stop", theNodeId, theModuleId) 459 .retrieve() 460 .body(JsonNode.class); 461 return Objects.requireNonNull(result); 462 } 463 464 /** 465 * Request to restart a module on all processes. Blocks until the module reports {@value #TARGET_STATUS} 466 * (up to {@link #DEFAULT_TIMEOUT}). Use {@link #restartModule(String, String, boolean)} to opt out of the wait. 467 */ 468 @Nonnull 469 public JsonNode restartModule(String theNodeId, String theModuleId) { 470 return restartModule(theNodeId, theModuleId, true, DEFAULT_TIMEOUT); 471 } 472 473 /** 474 * Request to restart a module on all processes. 475 * 476 * @param theShouldWaitForRestart when {@code true}, the client polls the admin status endpoint 477 * until the module reports {@value #TARGET_STATUS}, 478 * up to {@link #DEFAULT_TIMEOUT}. 479 */ 480 @Nonnull 481 public JsonNode restartModule(String theNodeId, String theModuleId, boolean theShouldWaitForRestart) { 482 return restartModule(theNodeId, theModuleId, theShouldWaitForRestart, DEFAULT_TIMEOUT); 483 } 484 485 /** 486 * Request to restart a module on all processes with a caller-supplied wait timeout. 487 * 488 * @param theShouldWaitForRestart when {@code true}, the client polls the admin status endpoint 489 * until the module reports {@value #TARGET_STATUS}, 490 * up to {@code theWaitTimeout}. 491 * @param theWaitTimeout the maximum duration to wait for the module to report {@value #TARGET_STATUS} 492 * when {@code theShouldWaitForRestart} is {@code true}. Ignored otherwise. 493 */ 494 @Nonnull 495 public JsonNode restartModule(String theNodeId, String theModuleId, boolean theShouldWaitForRestart, Duration theWaitTimeout) { 496 Validate.notEmpty(theNodeId, "Node ID is required"); 497 Validate.notEmpty(theModuleId, "Module ID is required"); 498 JsonNode result = myRestClient 499 .post() 500 .uri("module-config/{nodeId}/{moduleId}/restart", theNodeId, theModuleId) 501 .retrieve() 502 .body(JsonNode.class); 503 504 if (theShouldWaitForRestart) { 505 Validate.notNull(theWaitTimeout, "Wait timeout is required"); 506 waitForModuleStarted(theNodeId, theModuleId, theWaitTimeout); 507 } 508 509 return Objects.requireNonNull(result); 510 } 511 512 /** 513 * Package-private overload that accepts a custom timeout. Primarily intended for tests that need to 514 * exercise the timeout branch without waiting 90 seconds. 515 * 516 * Polls {@link #getNodeStatuses()} until the first process of the given module on the given node reports 517 * {@value #TARGET_STATUS}, up to {@code theTimeout}. Throws {@link IllegalStateException} on timeout 518 * or terminal failure ({@value #FAILED_TO_START} / {@value #FAILED_TO_STOP}). 519 */ 520 void waitForModuleStarted(String theNodeId, String theModuleId, Duration theTimeout) { 521 Validate.notEmpty(theNodeId, "Node ID is required"); 522 Validate.notEmpty(theModuleId, "Module ID is required"); 523 Validate.notNull(theTimeout, "Timeout is required"); 524 525 long deadlineNanos = System.nanoTime() + theTimeout.toNanos(); 526 int completedProbes = 0; 527 String lastObservedStatus = NEVER_OBSERVED; 528 RuntimeException lastException = null; 529 int transientErrorCount = 0; 530 531 while (true) { 532 ModuleStatusProbe probe = null; 533 try { 534 JsonNode statuses = getNodeStatuses(); 535 probe = extractModuleProcessStatus(statuses, theNodeId, theModuleId); 536 completedProbes++; 537 } catch (RestClientException e) { 538 // Narrow catch: only Spring HTTP-layer transients (RestClientException covers ResourceAccessException, 539 // HttpServerErrorException, HttpClientErrorException, etc.). Programmer errors like NPE, IAE, ISE 540 // from extractModuleProcessStatus or elsewhere propagate up immediately so they aren't masked 541 // by a 90s timeout. 542 lastException = e; 543 transientErrorCount++; 544 ourLog.debug("Transient error while polling node statuses for module '{}' on node '{}' (transient #{}): {}", 545 theModuleId, theNodeId, transientErrorCount, e.toString()); 546 } 547 548 if (probe != null) { 549 lastObservedStatus = probe.presence(); 550 if (probe.rawStatus() != null) { 551 if (TARGET_STATUS.equals(probe.rawStatus())) { 552 ourLog.debug("Module '{}' on node '{}' reported {} after {} completed probe(s)", 553 theModuleId, theNodeId, TARGET_STATUS, completedProbes); 554 return; 555 } 556 if (probe.terminalFailure()) { 557 // Throw terminal-failure OUTSIDE the try/catch above so it is never classified as transient. 558 throw new IllegalStateException(String.format( 559 "Module '%s' on node '%s' reached terminal failure status '%s' after %d completed probe(s)", 560 theModuleId, theNodeId, probe.rawStatus(), completedProbes)); 561 } 562 } 563 } 564 565 if (System.nanoTime() >= deadlineNanos) { 566 String message = String.format( 567 "Timed out after %d ms waiting for module '%s' on node '%s' to reach status '%s' (last observed status: %s, poll attempts: %d, transient errors: %d)", 568 theTimeout.toMillis(), theModuleId, theNodeId, TARGET_STATUS, lastObservedStatus, completedProbes, transientErrorCount); 569 if (lastException != null) { 570 // Use the FQN so subclasses (e.g. HttpServerErrorException$InternalServerError) still surface 571 // the parent class name in the timeout message. 572 String causeSummary = lastException.getClass().getName() + ": " + lastException.getMessage(); 573 throw new IllegalStateException(message + "; last error: " + causeSummary, lastException); 574 } 575 throw new IllegalStateException(message); 576 } 577 578 try { 579 mySleeper.sleep(POLL_INTERVAL.toMillis()); 580 } catch (InterruptedException e) { 581 Thread.currentThread().interrupt(); 582 throw new IllegalStateException( 583 "Interrupted while waiting for module '" + theModuleId + "' on node '" + theNodeId + "' to start", 584 e); 585 } 586 } 587 } 588 589 /** 590 * Result of probing the {@code node-statuses/complete} payload for a specific node/module. 591 * 592 * <p>The record stores only {@code rawStatus} and (when {@code rawStatus} is {@code null}) an 593 * {@code absenceLabel} that explains why no status was observed ({@value #NOT_PRESENT} or 594 * {@value #NO_PROCESSES}). The observability-friendly {@link #presence()} label and the 595 * {@link #terminalFailure()} flag are derived from {@code rawStatus} so there is a single source 596 * of truth and no two fields to keep in sync. 597 * 598 * @param rawStatus the raw {@code processes[0].status} string, or {@code null} when the module row was 599 * absent, the processes array was empty, or the status field was missing. 600 * @param absenceLabel when {@code rawStatus} is {@code null}, one of {@value #NOT_PRESENT} or 601 * {@value #NO_PROCESSES}. Ignored (and may be {@code null}) when {@code rawStatus} 602 * is non-null. 603 */ 604 private record ModuleStatusProbe(@Nullable String rawStatus, @Nullable String absenceLabel) { 605 boolean terminalFailure() { 606 return FAILED_TO_START.equals(rawStatus) || FAILED_TO_STOP.equals(rawStatus); 607 } 608 609 String presence() { 610 return rawStatus != null ? rawStatus : absenceLabel; 611 } 612 } 613 614 /** 615 * Walk the raw {@code node-statuses/complete} payload to extract the first process status for the given 616 * {@code nodeId} / {@code moduleId}. 617 * 618 * Expected shape: {@code { "nodes": [ { "nodeId": "...", "modules": [ { "moduleId": "...", "processes": [ { "status": "..." } ] } ] } ] } }. 619 */ 620 @Nonnull 621 private static ModuleStatusProbe extractModuleProcessStatus(JsonNode theStatuses, String theNodeId, String theModuleId) { 622 if (theStatuses == null) { 623 return new ModuleStatusProbe(null, NOT_PRESENT); 624 } 625 JsonNode nodes = theStatuses.get("nodes"); 626 if (nodes == null || !nodes.isArray()) { 627 return new ModuleStatusProbe(null, NOT_PRESENT); 628 } 629 for (JsonNode node : nodes) { 630 JsonNode nodeId = node.get("nodeId"); 631 if (nodeId == null || !theNodeId.equals(nodeId.asText())) { 632 continue; 633 } 634 JsonNode modules = node.get("modules"); 635 if (modules == null || !modules.isArray()) { 636 continue; 637 } 638 for (JsonNode module : modules) { 639 JsonNode moduleId = module.get("moduleId"); 640 if (moduleId == null || !theModuleId.equals(moduleId.asText())) { 641 continue; 642 } 643 JsonNode processes = module.get("processes"); 644 if (processes == null || !processes.isArray() || processes.isEmpty()) { 645 // Node+module matched, but no processes are reporting yet. 646 return new ModuleStatusProbe(null, NO_PROCESSES); 647 } 648 JsonNode status = processes.get(0).get("status"); 649 if (status == null) { 650 return new ModuleStatusProbe(null, NO_PROCESSES); 651 } 652 return new ModuleStatusProbe(status.asText(), null); 653 } 654 } 655 return new ModuleStatusProbe(null, NOT_PRESENT); 656 } 657 658 /** 659 * Get a list of restore points for a node 660 */ 661 @Nonnull 662 public JsonNode getRestorePoints( 663 String theNodeId, @Nullable String theVersion, @Nullable String theFromDate, @Nullable String theToDate, int theOffset, int theCount) { 664 Validate.notEmpty(theNodeId, "Node ID is required"); 665 666 JsonNode result = myRestClient.get() 667 .uri("module-config/{theNodeId}/restorePoints", builder->{ 668 builder.queryParamIfPresent("version", Optional.ofNullable(theVersion)); 669 builder.queryParamIfPresent("from", Optional.ofNullable(theFromDate)); 670 builder.queryParamIfPresent("to", Optional.ofNullable(theToDate)); 671 builder.queryParam("offset", theOffset); 672 builder.queryParam("count", theCount); 673 return builder.build(theNodeId); 674 }).retrieve().body(JsonNode.class); 675 676 return Objects.requireNonNull(result); 677 } 678 679 /** 680 * Get a specific restore point 681 */ 682 @Nonnull 683 public JsonNode getRestorePoint(String theNodeId, Long theId) { 684 Validate.notEmpty(theNodeId, "Node ID is required"); 685 Validate.notNull(theId, "Restore point ID is required"); 686 JsonNode result = myRestClient 687 .get() 688 .uri("module-config/{nodeId}/restorePoints/{id}", theNodeId, theId) 689 .retrieve() 690 .body(JsonNode.class); 691 return Objects.requireNonNull(result); 692 } 693 694 /** 695 * Restore the system to a specific restore point 696 */ 697 public void restoreSystem(String theNodeId, Long theRestorePointId) { 698 Validate.notEmpty(theNodeId, "Node ID is required"); 699 Validate.notNull(theRestorePointId, "Restore point ID is required"); 700 myRestClient 701 .post() 702 .uri("module-config/{nodeId}/restorePoints/{id}/restore", theNodeId, theRestorePointId) 703 .retrieve() 704 .toBodilessEntity(); 705 } 706 707 /** 708 * Get health checks for all modules 709 */ 710 @Nonnull 711 public JsonNode getHealthChecks(boolean theOnlyRunning) { 712 JsonNode result = myRestClient.get().uri("runtime-status/node-statuses/health-checks?onlyRunning={onlyRunning}", theOnlyRunning).retrieve().body(JsonNode.class); 713 return Objects.requireNonNull(result); 714 } 715 716 /** 717 * Get node statuses 718 */ 719 @Nonnull 720 public JsonNode getNodeStatuses() { 721 JsonNode result = myRestClient 722 .get() 723 .uri("runtime-status/node-statuses/complete") 724 .retrieve() 725 .body(JsonNode.class); 726 return Objects.requireNonNull(result); 727 } 728 729 /** 730 * Cancel a batch job 731 */ 732 public void cancelBatchJob(String theModuleId, String theJobId) { 733 Validate.notEmpty(theModuleId, "Module ID is required"); 734 Validate.notEmpty(theJobId, "Job ID is required"); 735 myRestClient 736 .get() 737 .uri("batch2-jobs/modules/{moduleId}/jobs/{jobId}/cancel", theModuleId, theJobId) 738 .retrieve() 739 .toBodilessEntity(); 740 } 741 742 /** 743 * Get all module IDs that support batch jobs 744 */ 745 @Nonnull 746 public String[] getAllBatchJobModuleIds() { 747 String[] result = myRestClient 748 .get() 749 .uri("batch2-jobs/modules") 750 .retrieve() 751 .body(String[].class); 752 return Objects.requireNonNull(result); 753 } 754 755 /** 756 * Get a specific batch job instance by ID 757 */ 758 @Nonnull 759 public Batch2JobInstanceJson getBatchJobInstance(String theModuleId, String theJobId) { 760 Validate.notEmpty(theModuleId, "Module ID is required"); 761 Validate.notEmpty(theJobId, "Job ID is required"); 762 Batch2JobInstanceJson result = myRestClient 763 .get() 764 .uri("batch2-jobs/modules/{moduleId}/jobs/{jobId}", theModuleId, theJobId) 765 .retrieve() 766 .body(Batch2JobInstanceJson.class); 767 return Objects.requireNonNull(result); 768 } 769 770 @Nonnull 771 public AllBatch2JobInstancesJson getBatchJobInstances(String theModuleId) { 772 return getBatchJobInstances(theModuleId, new LinkedMultiValueMap<>()); 773 } 774 775 @Nonnull 776 public AllBatch2JobInstancesJson getBatchJobInstances( 777 String theModuleId, @Nonnull MultiValueMap<String, String> theQueryParams) { 778 Validate.notEmpty(theModuleId, "Module ID is required"); 779 780 AllBatch2JobInstancesJson result = myRestClient 781 .get() 782 .uri("batch2-jobs/modules/{moduleId}", builder -> builder.queryParams(theQueryParams).build(theModuleId)) 783 .retrieve() 784 .body(AllBatch2JobInstancesJson.class); 785 return Objects.requireNonNull(result); 786 } 787 788 @Nonnull 789 public Batch2JobInstancesJson getBatchJobInstancesByName( 790 String theModuleId, String theJobDefinitionId, @Nullable Boolean theEnded, int theStart, int theCount) { 791 Validate.notEmpty(theModuleId, "Module ID is required"); 792 Validate.notEmpty(theJobDefinitionId, "Job definition ID is required"); 793 794 Batch2JobInstancesJson result = myRestClient 795 .get() 796 .uri("batch2-jobs/modules/{moduleId}/names/{name}/jobs", builder -> { 797 builder.queryParam("start", theStart); 798 builder.queryParam("count", theCount); 799 builder.queryParamIfPresent("ended", Optional.ofNullable(theEnded)); 800 return builder.build(theModuleId, theJobDefinitionId); 801 }) 802 .retrieve() 803 .body(Batch2JobInstancesJson.class); 804 return Objects.requireNonNull(result); 805 } 806 807 /** 808 * Process a bulk ETL import file 809 * @param theModuleId the id of the etl module to target 810 * @param theFilename the optional filename as a hint for the import process 811 * @param theContent the body of the csv file to import 812 */ 813 @Nonnull 814 public EtlImportProcessFileResponseJson processBulkImport(@Nonnull String theModuleId, @Nullable String theFilename, @Nonnull String theContent) { 815 return processBulkImport(theModuleId, theFilename, null, theContent); 816 } 817 818 /** 819 * Process a bulk ETL import file 820 * @param theModuleId the id of the etl module to target 821 * @param theFilename the optional filename as a hint for the import process 822 * @param theUserJobType the optional job type to use for the import process 823 * @param theContent the body of the csv file to import 824 */ 825 @Nonnull 826 public EtlImportProcessFileResponseJson processBulkImport(@Nonnull String theModuleId, @Nullable String theFilename, @Nullable String theUserJobType, @Nonnull String theContent) { 827 Validate.notEmpty(theModuleId, "Module ID is required"); 828 Validate.notNull(theContent, "Content is required"); 829 830 EtlImportProcessFileResponseJson result = myRestClient 831 .post() 832 .uri("bulk-import/process-etl-file/{moduleId}", builder-> { 833 builder.queryParamIfPresent("filename", Optional.ofNullable(theFilename)); 834 builder.queryParamIfPresent("userJobType", Optional.ofNullable(theUserJobType)); 835 return builder.build(Map.of("moduleId", theModuleId)); 836 }) 837 .accept(MediaType.APPLICATION_JSON) 838 .body(theContent) 839 .retrieve() 840 .body(EtlImportProcessFileResponseJson.class); 841 return Objects.requireNonNull(result); 842 } 843 844 /** 845 * Get transaction logs 846 */ 847 @Nonnull 848 public TransactionLogEventsJson getTransactionLogs() { 849 TransactionLogEventsJson result = 850 myRestClient.get().uri("transaction-log").retrieve().body(TransactionLogEventsJson.class); 851 return Objects.requireNonNull(result); 852 } 853 854 @Nonnull 855 public TransactionLogEventsJson getTransactionLogs(@Nonnull MultiValueMap<String, String> theQueryParams) { 856 TransactionLogEventsJson result = myRestClient 857 .get() 858 .uri("transaction-log", builder -> builder.queryParams(theQueryParams).build()) 859 .retrieve() 860 .body(TransactionLogEventsJson.class); 861 return Objects.requireNonNull(result); 862 } 863 864 @Nonnull 865 public TransactionLogEventCodesJson getTransactionLogEventCodes() { 866 TransactionLogEventCodesJson result = myRestClient 867 .get() 868 .uri("transaction-log/event-codes") 869 .retrieve() 870 .body(TransactionLogEventCodesJson.class); 871 return Objects.requireNonNull(result); 872 } 873 874 @Nonnull 875 public AuditEventsJson getAuditEvents() { 876 return getAuditEvents(new LinkedMultiValueMap<>()); 877 } 878 879 @Nonnull 880 public AuditEventsJson getAuditEvents(@Nonnull MultiValueMap<String, String> theQueryParams) { 881 AuditEventsJson result = myRestClient 882 .get() 883 .uri("audit-log", builder -> builder.queryParams(theQueryParams).build()) 884 .retrieve() 885 .body(AuditEventsJson.class); 886 return Objects.requireNonNull(result); 887 } 888 889 @Nonnull 890 public MdmAlgorithmsListJson getMdmAlgorithms(String theModuleId) { 891 Validate.notEmpty(theModuleId, "Module ID is required"); 892 893 MdmAlgorithmsListJson result = myRestClient 894 .get() 895 .uri("mdm/{moduleId}/mdm-algorithms", theModuleId) 896 .retrieve() 897 .body(MdmAlgorithmsListJson.class); 898 return Objects.requireNonNull(result); 899 } 900 901 @Nonnull 902 public MdmMetricsJson getMdmMetrics(String theModuleId, String theResourceType) { 903 Validate.notEmpty(theModuleId, "Module ID is required"); 904 Validate.notEmpty(theResourceType, "Resource type is required"); 905 906 MdmMetricsJson result = myRestClient 907 .get() 908 .uri("mdm/{moduleId}/mdm-metrics?resourceType={resourceType}", theModuleId, theResourceType) 909 .retrieve() 910 .body(MdmMetricsJson.class); 911 return Objects.requireNonNull(result); 912 } 913 914 @Nonnull 915 public Set<LoggerConfigJson> getTroubleshootingLoggers() { 916 Set<LoggerConfigJson> result = myRestClient 917 .get() 918 .uri("troubleshooting-log/configuration") 919 .retrieve() 920 .body(new ParameterizedTypeReference<>() {}); 921 return Objects.requireNonNull(result); 922 } 923 924 @Nonnull 925 public Set<LoggerConfigJson> updateTroubleshootingLoggers(@Nonnull Set<LoggerConfigJson> theLoggers) { 926 Set<LoggerConfigJson> result = myRestClient 927 .post() 928 .uri("troubleshooting-log/configuration") 929 .contentType(MediaType.APPLICATION_JSON) 930 .body(theLoggers) 931 .retrieve() 932 .body(new ParameterizedTypeReference<>() {}); 933 return Objects.requireNonNull(result); 934 } 935 936 @Nonnull 937 public LoggerConfigJson updateTroubleshootingLoggerLevel(String theLoggerName, String theLoggerLevel) { 938 Validate.notEmpty(theLoggerName, "Logger name is required"); 939 Validate.notEmpty(theLoggerLevel, "Logger level is required"); 940 941 LoggerConfigJson result = myRestClient 942 .put() 943 .uri("troubleshooting-log/configuration/{loggerName}?loggerLevel={loggerLevel}", theLoggerName, theLoggerLevel) 944 .retrieve() 945 .body(LoggerConfigJson.class); 946 return Objects.requireNonNull(result); 947 } 948 949 /** 950 * Get a specific transaction log event with its body 951 */ 952 @Nonnull 953 public TransactionLogEventsJson.TransactionLogEventJson getTransactionLogEvent( 954 Long theEventId, boolean theIncludeBody) { 955 Validate.notNull(theEventId, "Event ID is required"); 956 957 TransactionLogEventsJson.TransactionLogEventJson result = 958 myRestClient.get().uri("transaction-log/event/{theEventId}?includeBody={includeBody}", theEventId, theIncludeBody).retrieve().body(TransactionLogEventsJson.TransactionLogEventJson.class); 959 return Objects.requireNonNull(result); 960 } 961 962 /** 963 * Create an OAuth client for a specific module 964 */ 965 @Nonnull 966 public OAuth2ClientDetailsJson createOAuthClient( 967 String theNodeId, String theModuleId, OAuth2WritableClientDetailsJson theClientDetails) { 968 Validate.notEmpty(theNodeId, "Node ID is required"); 969 Validate.notEmpty(theModuleId, "Module ID is required"); 970 Validate.notEmpty(theClientDetails.getClientId(), "Client ID is required"); 971 Validate.notNull(theClientDetails, "Client details are required"); 972 973 OAuth2ClientDetailsJson result; 974 try { 975 result = myRestClient 976 .post() 977 .uri("openid-connect-clients/{nodeId}/{moduleId}", theNodeId, theModuleId) 978 .body(theClientDetails) 979 .retrieve() 980 .body(OAuth2ClientDetailsJson.class); 981 } catch (HttpClientErrorException e) { 982 ourLog.warn("Failed to create OIDC client, going to try update instead: " + e.toString()); 983 result = myRestClient 984 .post() 985 .uri("openid-connect-clients/{nodeId}/{moduleId}/{clientId}", theNodeId, theModuleId, theClientDetails.getClientId()) 986 .body(theClientDetails) 987 .retrieve() 988 .body(OAuth2ClientDetailsJson.class); 989 } 990 return Objects.requireNonNull(result); 991 } 992 993 /** 994 * Update an OAuth client for a specific module 995 */ 996 @Nonnull 997 public OAuth2ClientDetailsJson updateOAuthClient( 998 String theNodeId, 999 String theModuleId, 1000 String theClientId, 1001 OAuth2WritableClientDetailsJson theClientDetails) { 1002 Validate.notEmpty(theNodeId, "Node ID is required"); 1003 Validate.notEmpty(theModuleId, "Module ID is required"); 1004 Validate.notEmpty(theClientId, "Client ID is required"); 1005 Validate.notNull(theClientDetails, "Client details are required"); 1006 1007 OAuth2ClientDetailsJson result = myRestClient 1008 .put() 1009 .uri("openid-connect-clients/{nodeId}/{moduleId}/{clientId}", Map.of( 1010 "nodeId", theNodeId, 1011 "moduleId", theModuleId, 1012 "clientId", theClientId)) 1013 .body(theClientDetails) 1014 .retrieve() 1015 .body(OAuth2ClientDetailsJson.class); 1016 return Objects.requireNonNull(result); 1017 } 1018 1019 /** 1020 * Delete an OAuth client 1021 */ 1022 public void deleteOAuthClient(String theNodeId, String theModuleId, String theClientId) { 1023 Validate.notEmpty(theNodeId, "Node ID is required"); 1024 Validate.notEmpty(theModuleId, "Module ID is required"); 1025 Validate.notEmpty(theClientId, "Client ID is required"); 1026 1027 myRestClient 1028 .delete() 1029 .uri("openid-connect-clients/{nodeId}/{moduleId}/{clientId}", theNodeId, theModuleId, theClientId) 1030 .retrieve() 1031 .toBodilessEntity(); 1032 } 1033 1034 @Nonnull 1035 public OAuth2ClientsJson getOAuthClients(int thePageIndex, int thePageSize, @Nullable ClientStatusFilterEnum theClientStatus) { 1036 OAuth2ClientsJson result = myRestClient 1037 .get() 1038 .uri("openid-connect-clients", builder -> { 1039 builder.queryParam("pageIndex", thePageIndex); 1040 builder.queryParam("pageSize", thePageSize); 1041 builder.queryParamIfPresent("clientStatus", Optional.ofNullable(theClientStatus)); 1042 return builder.build(); 1043 }) 1044 .retrieve() 1045 .body(OAuth2ClientsJson.class); 1046 return Objects.requireNonNull(result); 1047 } 1048 1049 /** 1050 * Get an OAuth client 1051 */ 1052 @Nonnull 1053 public OAuth2ClientDetailsJson getOAuthClient(String theNodeId, String theModuleId, String theClientId) { 1054 Validate.notEmpty(theNodeId, "Node ID is required"); 1055 Validate.notEmpty(theModuleId, "Module ID is required"); 1056 Validate.notEmpty(theClientId, "Client ID is required"); 1057 1058 OAuth2ClientDetailsJson result = myRestClient 1059 .get() 1060 .uri("openid-connect-clients/{nodeId}/{moduleId}/{clientId}", theNodeId, theModuleId, theClientId) 1061 .retrieve() 1062 .body(OAuth2ClientDetailsJson.class); 1063 return Objects.requireNonNull(result); 1064 } 1065 1066 /** 1067 * Create an OIDC server for a specific module 1068 */ 1069 @Nonnull 1070 public JsonNode createOidcServer(String theNodeId, String theModuleId, JsonNode theServerDetails) { 1071 Validate.notEmpty(theNodeId, "Node ID is required"); 1072 Validate.notEmpty(theModuleId, "Module ID is required"); 1073 Validate.notNull(theServerDetails, "Server details are required"); 1074 1075 JsonNode result = myRestClient 1076 .post() 1077 .uri("openid-connect-servers/{nodeId}/{moduleId}", theNodeId, theModuleId) 1078 .body(theServerDetails) 1079 .retrieve() 1080 .body(JsonNode.class); 1081 return Objects.requireNonNull(result); 1082 } 1083 1084 @Nonnull 1085 public OAuth2ServerJson createOidcServer(String theNodeId, String theModuleId, @Nonnull OAuth2ServerJson theServer) { 1086 Validate.notEmpty(theNodeId, "Node ID is required"); 1087 Validate.notEmpty(theModuleId, "Module ID is required"); 1088 Validate.notNull(theServer, "Server is required"); 1089 1090 OAuth2ServerJson result = myRestClient 1091 .post() 1092 .uri("openid-connect-servers/{nodeId}/{moduleId}", theNodeId, theModuleId) 1093 .contentType(MediaType.APPLICATION_JSON) 1094 .body(theServer) 1095 .retrieve() 1096 .body(OAuth2ServerJson.class); 1097 return Objects.requireNonNull(result); 1098 } 1099 1100 @Nonnull 1101 public OAuth2ServerJson getOidcServer(String theNodeId, String theModuleId, String theIssuerUrl) { 1102 Validate.notEmpty(theNodeId, "Node ID is required"); 1103 Validate.notEmpty(theModuleId, "Module ID is required"); 1104 Validate.notEmpty(theIssuerUrl, "Issuer URL is required"); 1105 1106 OAuth2ServerJson result = myRestClient 1107 .get() 1108 .uri("openid-connect-servers/{nodeId}/{moduleId}?issuer_url={issuerUrl}", theNodeId, theModuleId, theIssuerUrl) 1109 .retrieve() 1110 .body(OAuth2ServerJson.class); 1111 return Objects.requireNonNull(result); 1112 } 1113 1114 @Nonnull 1115 public OAuth2ServersJson getOidcServers( 1116 @Nullable String theNodeId, @Nullable String theModuleId, int thePageIndex, int thePageSize) { 1117 OAuth2ServersJson result = myRestClient 1118 .get() 1119 .uri("openid-connect-servers", builder -> { 1120 builder.queryParamIfPresent("node_id", Optional.ofNullable(theNodeId)); 1121 builder.queryParamIfPresent("module_id", Optional.ofNullable(theModuleId)); 1122 builder.queryParam("pageIndex", thePageIndex); 1123 builder.queryParam("pageSize", thePageSize); 1124 return builder.build(); 1125 }) 1126 .retrieve() 1127 .body(OAuth2ServersJson.class); 1128 return Objects.requireNonNull(result); 1129 } 1130 1131 /** 1132 * Update an OIDC server 1133 */ 1134 @Nonnull 1135 public JsonNode updateOidcServer( 1136 String theNodeId, String theModuleId, String theServerId, JsonNode theServerDetails) { 1137 Validate.notEmpty(theNodeId, "Node ID is required"); 1138 Validate.notEmpty(theModuleId, "Module ID is required"); 1139 Validate.notEmpty(theServerId, "Server ID is required"); 1140 Validate.notNull(theServerDetails, "Server details are required"); 1141 1142 JsonNode result = myRestClient 1143 .put() 1144 .uri("openid-connect-servers/{nodeId}/{moduleId}/{serverId}", theNodeId, theModuleId, theServerId) 1145 .body(theServerDetails) 1146 .retrieve() 1147 .body(JsonNode.class); 1148 return Objects.requireNonNull(result); 1149 } 1150 1151 @Nonnull 1152 public OAuth2ServerJson updateOidcServer(String theNodeId, String theModuleId, @Nonnull OAuth2ServerJson theServer) { 1153 Validate.notEmpty(theNodeId, "Node ID is required"); 1154 Validate.notEmpty(theModuleId, "Module ID is required"); 1155 Validate.notNull(theServer, "Server is required"); 1156 Validate.notNull(theServer.getPid(), "Server PID is required"); 1157 1158 OAuth2ServerJson result = myRestClient 1159 .put() 1160 .uri("openid-connect-servers/{nodeId}/{moduleId}/{pid}", theNodeId, theModuleId, theServer.getPid()) 1161 .contentType(MediaType.APPLICATION_JSON) 1162 .body(theServer) 1163 .retrieve() 1164 .body(OAuth2ServerJson.class); 1165 return Objects.requireNonNull(result); 1166 } 1167 1168 @Nonnull 1169 public CdaTemplateJson createCdaTemplate( 1170 String theModuleId, String theTemplateId, @Nonnull CdaTemplateRequestJson theTemplate) { 1171 return writeCdaTemplate(myRestClient.post(), theModuleId, theTemplateId, theTemplate); 1172 } 1173 1174 @Nonnull 1175 public CdaTemplateJson updateCdaTemplate( 1176 String theModuleId, String theTemplateId, @Nonnull CdaTemplateRequestJson theTemplate) { 1177 return writeCdaTemplate(myRestClient.put(), theModuleId, theTemplateId, theTemplate); 1178 } 1179 1180 private CdaTemplateJson writeCdaTemplate( 1181 RestClient.RequestBodyUriSpec theMethod, 1182 String theModuleId, 1183 String theTemplateId, 1184 CdaTemplateRequestJson theTemplate) { 1185 Validate.notEmpty(theModuleId, "Module ID is required"); 1186 Validate.notEmpty(theTemplateId, "Template ID is required"); 1187 Validate.notNull(theTemplate, "Template is required"); 1188 1189 CdaTemplateJson result = theMethod 1190 .uri("cda/{moduleId}/template/{templateId}", theModuleId, theTemplateId) 1191 .contentType(MediaType.APPLICATION_JSON) 1192 .body(theTemplate) 1193 .retrieve() 1194 .body(CdaTemplateJson.class); 1195 return Objects.requireNonNull(result); 1196 } 1197 1198 @Nonnull 1199 public CdaTemplateJson updateCdaTemplateScript(String theModuleId, String theTemplateId, String theScript) { 1200 Validate.notEmpty(theModuleId, "Module ID is required"); 1201 Validate.notEmpty(theTemplateId, "Template ID is required"); 1202 Validate.notNull(theScript, "Script is required"); 1203 1204 CdaTemplateJson result = myRestClient 1205 .put() 1206 .uri("cda/{moduleId}/template/{templateId}/script", theModuleId, theTemplateId) 1207 .contentType(MediaType.parseMediaType("application/javascript")) 1208 .body(theScript) 1209 .retrieve() 1210 .body(CdaTemplateJson.class); 1211 return Objects.requireNonNull(result); 1212 } 1213 1214 @Nonnull 1215 public CdaTemplateViewAllResponseJson getCdaTemplates(String theModuleId) { 1216 Validate.notEmpty(theModuleId, "Module ID is required"); 1217 1218 CdaTemplateViewAllResponseJson result = myRestClient 1219 .get() 1220 .uri("cda/{moduleId}/template", theModuleId) 1221 .retrieve() 1222 .body(CdaTemplateViewAllResponseJson.class); 1223 return Objects.requireNonNull(result); 1224 } 1225 1226 @Nonnull 1227 public CdaTemplateResponseJson getCdaTemplate(String theModuleId, String theTemplateId) { 1228 Validate.notEmpty(theModuleId, "Module ID is required"); 1229 Validate.notEmpty(theTemplateId, "Template ID is required"); 1230 1231 CdaTemplateResponseJson result = myRestClient 1232 .get() 1233 .uri("cda/{moduleId}/template/{templateId}", theModuleId, theTemplateId) 1234 .retrieve() 1235 .body(CdaTemplateResponseJson.class); 1236 return Objects.requireNonNull(result); 1237 } 1238 1239 public void deleteCdaTemplate(String theModuleId, String theTemplateId) { 1240 Validate.notEmpty(theModuleId, "Module ID is required"); 1241 Validate.notEmpty(theTemplateId, "Template ID is required"); 1242 1243 myRestClient 1244 .delete() 1245 .uri("cda/{moduleId}/template/{templateId}", theModuleId, theTemplateId) 1246 .retrieve() 1247 .toBodilessEntity(); 1248 } 1249 1250 @Nonnull 1251 public PrivacyNoticeJson getPrivacyNotice() { 1252 PrivacyNoticeJson result = myRestClient 1253 .get() 1254 .uri("admin-json/privacy-notice") 1255 .retrieve() 1256 .body(PrivacyNoticeJson.class); 1257 return Objects.requireNonNull(result); 1258 } 1259 1260 @Nonnull 1261 public PrivacyNoticeStatusJson getPrivacyNoticeStatus() { 1262 PrivacyNoticeStatusJson result = myRestClient 1263 .get() 1264 .uri("admin-json/privacy-notice/status") 1265 .retrieve() 1266 .body(PrivacyNoticeStatusJson.class); 1267 return Objects.requireNonNull(result); 1268 } 1269 1270 @Nonnull 1271 public PrivacyNoticeAcceptanceJson acceptPrivacyNotice(String theVersion) { 1272 Validate.notEmpty(theVersion, "Version is required"); 1273 1274 PrivacyNoticeAcceptanceJson result = myRestClient 1275 .post() 1276 .uri("admin-json/privacy-notice/accept") 1277 .contentType(MediaType.APPLICATION_JSON) 1278 .body(new PrivacyNoticeAcceptRequestJson(theVersion)) 1279 .retrieve() 1280 .body(PrivacyNoticeAcceptanceJson.class); 1281 return Objects.requireNonNull(result); 1282 } 1283 1284 @Nonnull 1285 public OAuth2KeystoresJson getKeystores(int thePageIndex, int thePageSize) { 1286 OAuth2KeystoresJson result = myRestClient 1287 .get() 1288 .uri("keystores?pageIndex={pageIndex}&pageSize={pageSize}", thePageIndex, thePageSize) 1289 .retrieve() 1290 .body(OAuth2KeystoresJson.class); 1291 return Objects.requireNonNull(result); 1292 } 1293 1294 @Nonnull 1295 public OAuth2KeystoreJson getKeystore(String theKeystoreId) { 1296 Validate.notEmpty(theKeystoreId, "Keystore ID is required"); 1297 1298 OAuth2KeystoreJson result = myRestClient 1299 .get() 1300 .uri("keystores/{keystoreId}", theKeystoreId) 1301 .retrieve() 1302 .body(OAuth2KeystoreJson.class); 1303 return Objects.requireNonNull(result); 1304 } 1305 1306 @Nonnull 1307 public OAuth2KeystoreJson createKeystore(@Nonnull OAuth2KeystoreJson theKeystore) { 1308 Validate.notNull(theKeystore, "Keystore is required"); 1309 1310 OAuth2KeystoreJson result = myRestClient 1311 .post() 1312 .uri("keystores") 1313 .contentType(MediaType.APPLICATION_JSON) 1314 .body(theKeystore) 1315 .retrieve() 1316 .body(OAuth2KeystoreJson.class); 1317 return Objects.requireNonNull(result); 1318 } 1319 1320 @Nonnull 1321 public OAuth2KeystoreJson updateKeystore(String theKeystoreId, @Nonnull OAuth2KeystoreJson theKeystore) { 1322 Validate.notEmpty(theKeystoreId, "Keystore ID is required"); 1323 Validate.notNull(theKeystore, "Keystore is required"); 1324 1325 OAuth2KeystoreJson result = myRestClient 1326 .put() 1327 .uri("keystores/{keystoreId}", theKeystoreId) 1328 .contentType(MediaType.APPLICATION_JSON) 1329 .body(theKeystore) 1330 .retrieve() 1331 .body(OAuth2KeystoreJson.class); 1332 return Objects.requireNonNull(result); 1333 } 1334 1335 public void deleteKeystore(String theKeystoreId) { 1336 Validate.notEmpty(theKeystoreId, "Keystore ID is required"); 1337 1338 myRestClient 1339 .delete() 1340 .uri("keystores/{keystoreId}", theKeystoreId) 1341 .retrieve() 1342 .toBodilessEntity(); 1343 } 1344 1345 @Nonnull 1346 public OAuth2RevokeAllWithScopeResponseJson revokeAllTokens( 1347 @Nullable String theNodeId, @Nullable String theModuleId, @Nullable String theScope) { 1348 OAuth2RevokeAllWithScopeResponseJson result = myRestClient 1349 .delete() 1350 .uri("openid-connect-sessions/revoke/all-tokens", builder -> { 1351 builder.queryParamIfPresent("node_id", Optional.ofNullable(theNodeId)); 1352 builder.queryParamIfPresent("module_id", Optional.ofNullable(theModuleId)); 1353 builder.queryParamIfPresent("scope", Optional.ofNullable(theScope)); 1354 return builder.build(); 1355 }) 1356 .retrieve() 1357 .body(OAuth2RevokeAllWithScopeResponseJson.class); 1358 return Objects.requireNonNull(result); 1359 } 1360 1361 /** 1362 * Get MDM links 1363 */ 1364 @Nonnull 1365 public JsonNode getMdmLinks(String theModuleId) { 1366 Validate.notEmpty(theModuleId, "Module ID is required"); 1367 1368 JsonNode result = myRestClient 1369 .get() 1370 .uri("mdm/{moduleId}/query-links", theModuleId) 1371 .retrieve() 1372 .body(JsonNode.class); 1373 return Objects.requireNonNull(result); 1374 } 1375 1376 /** 1377 * Merge MDM golden resources 1378 */ 1379 @Nonnull 1380 public JsonNode mergeMdmGoldenResources(String theModuleId, Object theRequest) { 1381 Validate.notEmpty(theModuleId, "Module ID is required"); 1382 Validate.notNull(theRequest, "Request is required"); 1383 1384 JsonNode result = myRestClient 1385 .post() 1386 .uri("mdm/{moduleId}/merge-golden-resources", theModuleId) 1387 .body(theRequest) 1388 .retrieve() 1389 .body(JsonNode.class); 1390 return Objects.requireNonNull(result); 1391 } 1392 1393 /** 1394 * Update MDM link 1395 */ 1396 @Nonnull 1397 public JsonNode updateMdmLink(String theModuleId, Object theRequest) { 1398 Validate.notEmpty(theModuleId, "Module ID is required"); 1399 Validate.notNull(theRequest, "Request is required"); 1400 1401 JsonNode result = myRestClient 1402 .post() 1403 .uri("mdm/{moduleId}/update-link", theModuleId) 1404 .body(theRequest) 1405 .retrieve() 1406 .body(JsonNode.class); 1407 return Objects.requireNonNull(result); 1408 } 1409 1410 /** 1411 * Get MDM duplicate golden resources 1412 */ 1413 @Nonnull 1414 public JsonNode getMdmDuplicateGoldenResources(String theModuleId) { 1415 Validate.notEmpty(theModuleId, "Module ID is required"); 1416 1417 JsonNode result = myRestClient 1418 .get() 1419 .uri("mdm/{moduleId}/duplicate-golden-resources", theModuleId) 1420 .retrieve() 1421 .body(JsonNode.class); 1422 return Objects.requireNonNull(result); 1423 } 1424 1425 /** 1426 * Submit an MDM operation to perform batch matching on all resources 1427 * 1428 * @param theModuleId The module ID 1429 * @param theParameters The parameters for the MDM operation 1430 * @return The response as a String 1431 */ 1432 @Nonnull 1433 public String submitMdmOperation(String theModuleId, IBaseParameters theParameters) { 1434 Validate.notEmpty(theModuleId, "Module ID is required"); 1435 1436 String body = toJsonString(theParameters); 1437 1438 String result = myRestClient 1439 .post() 1440 .uri("{moduleId}/mdm-submit", theModuleId) 1441 .body(body) 1442 .retrieve() 1443 .body(String.class); 1444 1445 return result != null ? result : ""; 1446 } 1447 1448 public static String toJsonString(IBaseResource theResource) { 1449 FhirVersionEnum version = FhirVersionEnum.determineVersionForType(theResource.getClass()); 1450 FhirContext ctx = FhirContext.forCached(version); 1451 return ctx.newJsonParser().encodeResourceToString(theResource); 1452 } 1453 1454 /** 1455 * Exception thrown when a forbidden operation is attempted 1456 */ 1457 public static class ForbiddenOperationException extends RuntimeException { 1458 public ForbiddenOperationException(String message, Throwable cause) { 1459 super(message, cause); 1460 } 1461 } 1462 1463 /** 1464 * Get the node configurations from the admin API. 1465 * 1466 * @return The node configurations 1467 */ 1468 @Nonnull 1469 public NodeConfigurations getNodeConfigurations() { 1470 NodeConfigurations result = myRestClient 1471 .get() 1472 .uri("/module-config/") 1473 .retrieve() 1474 .body(NodeConfigurations.class); 1475 return Objects.requireNonNull(result); 1476 } 1477 1478 /** 1479 * Get the port number for a specific module from the node configurations. 1480 * 1481 * @param theModuleId The ID of the module to get the port for 1482 * @return The port number, or the default FHIR port (8000) if not found 1483 */ 1484 public int getPortFromModule(String theModuleId) { 1485 Validate.notEmpty(theModuleId, "Module ID is required"); 1486 NodeConfigurations config = getNodeConfigurations(); 1487 1488 // Find the first node (assuming there's only one node in the container) 1489 if (config.getNodes().isEmpty()) { 1490 throw new IllegalStateException("No nodes found in node configuration"); 1491 } else { 1492 NodeConfigurations.NodeConfiguration node = config.getNodes().get(0); 1493 1494 // Find the module with the given ID 1495 return node.getModule(theModuleId) 1496 .map(module -> { 1497 // Look for the port property in the module's configuration 1498 for (NodeConfigurations.ModuleConfigProperty property : module.getConfigProperties()) { 1499 if (property.getKey().equals("port")) { 1500 try { 1501 return Integer.parseInt(property.getValue()); 1502 } catch (NumberFormatException e) { 1503 // If the port is not a valid integer, fail. 1504 throw new IllegalArgumentException("Invalid port number: " + property.getValue(), e); 1505 } 1506 } 1507 } 1508 // If no port property is found, return the default FHIR port 1509 return null; 1510 }) 1511 .orElseThrow(() -> new IllegalArgumentException("Module with ID " + theModuleId + " not found in node configuration")); 1512 } 1513 1514 } 1515 1516 /** 1517 * Find the module ID of the FHIR endpoint (FHIR REST, hybrid providers, or FHIR gateway) running 1518 * on the given port. 1519 * 1520 * @param thePort The port to look up 1521 * @return The module ID of the FHIR endpoint module running on the given port 1522 * @throws IllegalArgumentException if no FHIR endpoint module is found running on the given port 1523 * @throws IllegalStateException if no nodes are found in the node configuration 1524 */ 1525 @Nonnull 1526 public String getFhirEndpointModuleIdFromPort(int thePort) { 1527 final NodeConfigurations config = getNodeConfigurations(); 1528 // Find the first node (assuming there's only one node in the container) 1529 if (config.getNodes().isEmpty()) { 1530 throw new IllegalStateException("No nodes found in node configuration"); 1531 } else { 1532 final NodeConfigurations.NodeConfiguration node = config.getNodes().get(0); 1533 // Find the module with the given ID 1534 return node.getModules().stream() 1535 .filter(theModuleConfiguration -> theModuleConfiguration.isFhirEndpointModuleType() 1536 && thePort == Integer.parseInt(theModuleConfiguration.getConfigProperty("port"))) 1537 .findFirst() 1538 .map(NodeConfigurations.ModuleConfiguration::getModuleId) 1539 .orElseThrow(() -> new IllegalArgumentException("Unable to find FHIR Endpoint module running on Port " + thePort)); 1540 } 1541 } 1542 1543 /** 1544 * Since many methods currently return JsonNode, if you have a known model you would like to bind to, you can convert a json object to it using this method 1545 */ 1546 public <T> T convertJsonNodeToModel(JsonNode jsonNode, Class<T> modelClass) throws JsonProcessingException { 1547 // This is a hack to work around many of our return types being hidden in cdr-api instead of cdr-api-public. 1548 // From Gary: We can't extract them cleanly yet to the fact that they often are relying on enums in clustermgr 1549 // or enums in api that we do not currently want to make public, 1550 // usually via 3 separate levels of nesting (e.g. node configurations -> module config -> ModuleTypeEnum 1551 // TODO: Fix these, and move them to public, updating the method types to our model classes. 1552 1553 try { 1554 ObjectMapper objectMapper = new ObjectMapper(); 1555 // Direct conversion from JsonNode to model class without string conversion 1556 return objectMapper.treeToValue(jsonNode, modelClass); 1557 } catch (JsonProcessingException e) { 1558 // Log the error with the actual JSON content for debugging 1559 ourLog.error("Failed to convert JsonNode to " + modelClass.getSimpleName() + 1560 ". JSON content: " + jsonNode.toString(), e); 1561 throw e; 1562 } 1563 } 1564 1565 public <T> JsonNode convertModelToJsonNode(T model) throws JsonProcessingException { 1566 try { 1567 ObjectMapper objectMapper = new ObjectMapper(); 1568 // Direct conversion from model to JsonNode without going through string 1569 return objectMapper.valueToTree(model); 1570 } catch (Exception e) { 1571 throw new JsonProcessingException("Error converting model to JsonNode") {} ; 1572 } 1573 } 1574 1575 1576 @Nullable 1577 public TransactionLogEventsJson.TransactionLogEventJson getMostRecentTransactionLogEntry() { 1578 TransactionLogEventsJson events = myRestClient.get().uri("/transaction-log/?pageSize=1").retrieve().body(TransactionLogEventsJson.class); 1579 if (events == null || events.getEvents() == null || events.getEvents().isEmpty()) { 1580 return null; 1581 } else { 1582 return events.getEvents().get(0); 1583 } 1584 } 1585 1586 @Nullable 1587 public Long getMostRecentTransactionLogEntryId() { 1588 TransactionLogEventsJson.TransactionLogEventJson entry = getMostRecentTransactionLogEntry(); 1589 if (entry != null) { 1590 return entry.getId(); 1591 } else { 1592 return null; 1593 } 1594 } 1595 1596 public void awaitNewTransactionLogEntry(Long theMostRecentTxLogEntryId) { 1597 await().until(this::getMostRecentTransactionLogEntryId, t -> !Objects.equals(theMostRecentTxLogEntryId, t)); 1598 } 1599 1600 /** 1601 * Fetch a transaction log entry by ID and return all details (includes the body) 1602 */ 1603 public TransactionLogEventsJson.TransactionLogEventJson getTransactionLogEntryById(Long theId) { 1604 return myRestClient.get().uri("/transaction-log/clustermgr/event/" +theId+ "?includeBody=true").retrieve().body(TransactionLogEventsJson.TransactionLogEventJson.class); 1605 } 1606 1607 /** 1608 * Perform a generic GET request to the specified path and return the response as JsonNode 1609 * 1610 * @param thePath The path to perform the GET request on 1611 * @return The response as JsonNode 1612 */ 1613 @Nonnull 1614 public JsonNode get(String thePath) { 1615 Validate.notEmpty(thePath, "Path is required"); 1616 JsonNode result = myRestClient 1617 .get() 1618 .uri(thePath) 1619 .retrieve() 1620 .body(JsonNode.class); 1621 return Objects.requireNonNull(result); 1622 } 1623 1624 /** 1625 * Releases the connection pool this client built for itself. A no-op when the client was built 1626 * over one supplied by a caller, which owns its own pool. 1627 */ 1628 @Override 1629 public void close() { 1630 if (myOwnership != null) { 1631 myOwnership.closeIfOwned(); 1632 } 1633 } 1634}