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