001package ca.cdr.test.app.clients; 002/*- 003 * #%L 004 * Smile CDR - CDR 005 * %% 006 * Copyright (C) 2016 - 2025 Smile CDR, Inc. 007 * %% 008 * All rights reserved. 009 * #L% 010 */ 011 012import ca.cdr.api.model.json.OAuth2ClientDetailsJson; 013import ca.cdr.api.model.json.OAuth2WritableClientDetailsJson; 014import ca.cdr.api.model.json.TransactionLogEventsJson; 015import ca.cdr.api.model.json.UserDetailsJson; 016import ca.cdr.test.model.NodeConfigurations; 017import ca.uhn.fhir.context.FhirContext; 018import ca.uhn.fhir.context.FhirVersionEnum; 019import com.fasterxml.jackson.core.JsonProcessingException; 020import com.fasterxml.jackson.databind.JsonNode; 021import com.fasterxml.jackson.databind.ObjectMapper; 022import jakarta.annotation.Nonnull; 023import jakarta.annotation.Nullable; 024import org.apache.commons.lang3.Validate; 025import org.apache.hc.client5.http.config.ConnectionConfig; 026import org.apache.hc.client5.http.config.RequestConfig; 027import org.apache.hc.client5.http.cookie.BasicCookieStore; 028import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; 029import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; 030import org.apache.hc.client5.http.socket.ConnectionSocketFactory; 031import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; 032import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; 033import org.apache.hc.core5.http.URIScheme; 034import org.apache.hc.core5.http.config.Registry; 035import org.apache.hc.core5.http.config.RegistryBuilder; 036import org.apache.hc.core5.pool.PoolConcurrencyPolicy; 037import org.apache.hc.core5.pool.PoolReusePolicy; 038import org.apache.hc.core5.util.TimeValue; 039import org.hl7.fhir.instance.model.api.IBaseParameters; 040import org.hl7.fhir.instance.model.api.IBaseResource; 041import org.jetbrains.annotations.NotNull; 042import org.slf4j.Logger; 043import org.slf4j.LoggerFactory; 044import org.springframework.http.MediaType; 045import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; 046import org.springframework.http.client.support.BasicAuthenticationInterceptor; 047import org.springframework.web.client.RestClient; 048 049import java.util.Map; 050import java.util.Objects; 051import java.util.concurrent.TimeUnit; 052 053import static org.testcontainers.shaded.org.awaitility.Awaitility.await; 054 055import java.util.Optional; 056 057// Many new methods generated by Claude Sonnet 3.7 058 059/** 060 * AdminJsonRestClient is a client for the Admin API of the CDR. 061 * It currently supports anonymous and http-basic authentication. 062 */ 063public class AdminJsonRestClient { 064 private static final Logger ourLog = LoggerFactory.getLogger(AdminJsonRestClient.class); 065 066 final RestClient myRestClient; 067 068 AdminJsonRestClient(RestClient theRestClient) { 069 myRestClient = theRestClient; 070 } 071 072 public static AdminJsonRestClient build(String theBaseUrl, String theUsername, String thePassword) { 073 RestClient restClient = builderForUrlWithJsonDefault(theBaseUrl) 074 .requestInterceptor(new BasicAuthenticationInterceptor(theUsername, thePassword)) 075 .build(); 076 077 return new AdminJsonRestClient(restClient); 078 } 079 080 public static AdminJsonRestClient buildAnonymous(String theBaseUrl) { 081 RestClient restClient = builderForUrlWithJsonDefault(theBaseUrl).build(); 082 083 return new AdminJsonRestClient(restClient); 084 } 085 086 private static @Nonnull RestClient.Builder builderForUrlWithJsonDefault(String theBaseUrl) { 087 HttpComponentsClientHttpRequestFactory requestFactory = buildSmileRequestFactory(); 088 089 return RestClient.builder() 090 .baseUrl(theBaseUrl) 091 .requestFactory(requestFactory) 092 // This sets the default content type to JSON, but allows the actual request to override it. 093 .defaultRequest(r -> r.accept(MediaType.APPLICATION_JSON)); 094 } 095 096 /** 097 * Set a massive timeout 098 * Disable Redirects. 099 * Set up local cookie storage. 100 */ 101 private static @Nonnull HttpComponentsClientHttpRequestFactory buildSmileRequestFactory() { 102 Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create() 103 .register(URIScheme.HTTP.id, PlainConnectionSocketFactory.getSocketFactory()) 104 .register(URIScheme.HTTPS.id, SSLConnectionSocketFactory.getSocketFactory()) 105 .build(); 106 107 TimeValue ttl = TimeValue.of(5000, TimeUnit.MILLISECONDS); 108 109 ConnectionConfig connectionConfig = ConnectionConfig.custom() 110 .setConnectTimeout(600000, TimeUnit.MILLISECONDS) 111 .setSocketTimeout(600000, TimeUnit.MILLISECONDS) 112 .build(); 113 114 PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager( 115 socketFactoryRegistry, PoolConcurrencyPolicy.LAX, PoolReusePolicy.LIFO, ttl); 116 connectionManager.setMaxTotal(99); 117 connectionManager.setDefaultMaxPerRoute(99); 118 connectionManager.setDefaultConnectionConfig(connectionConfig); 119 120 HttpClientBuilder httpClientBuilder = HttpClientBuilder.create(); 121 BasicCookieStore cookieStore = new BasicCookieStore(); 122 httpClientBuilder.setDefaultCookieStore(cookieStore); 123 httpClientBuilder.disableRedirectHandling(); 124 httpClientBuilder.setConnectionManager(connectionManager); 125 126 RequestConfig requestConfig = RequestConfig.custom() 127 .setConnectionRequestTimeout(600000, TimeUnit.MILLISECONDS) 128 .build(); 129 httpClientBuilder.setDefaultRequestConfig(requestConfig); 130 131 HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClientBuilder.build()); 132 return requestFactory; 133 } 134 135 136 @Nonnull 137 public UserDetailsJson userCreate(String theNode, String theModuleId, UserDetailsJson userDetails) { 138 Validate.notEmpty(theNode, "Node ID is required"); 139 Validate.notEmpty(theModuleId, "Module ID is required"); 140 Validate.notNull(userDetails, "User does not have an assigned pid. Use userCreate() to create users."); 141 UserDetailsJson result = myRestClient 142 .post() 143 .uri("user-management/{nodeId}/{moduleId}", theNode, theModuleId) 144 .body(userDetails) 145 .retrieve() 146 .body(UserDetailsJson.class); 147 return Objects.requireNonNull(result); 148 } 149 150 @Nonnull 151 public UserDetailsJson userUpdate(UserDetailsJson theUserDetails) { 152 Validate.notEmpty(theUserDetails.getNodeId(), "Node ID is required"); 153 Validate.notEmpty(theUserDetails.getModuleId(), "Module ID is required"); 154 Validate.notNull( 155 theUserDetails.getPid(), "User does not have an assigned pid. Use userCreate() to create users."); 156 UserDetailsJson result = myRestClient 157 .put() 158 .uri( 159 "user-management/{nodeId}/{moduleId}/{userId}", 160 theUserDetails.getNodeId(), 161 theUserDetails.getModuleId(), 162 theUserDetails.getPid()) 163 .body(theUserDetails) 164 .retrieve() 165 .body(UserDetailsJson.class); 166 return Objects.requireNonNull(result); 167 } 168 169 @Nonnull 170 public JsonNode userFindByUsername(String theNodeId, String theModuleId, String theUsername) { 171 Validate.notEmpty(theNodeId, "Node ID is required"); 172 Validate.notEmpty(theModuleId, "Module ID is required"); 173 Validate.notEmpty(theUsername, "username is required"); 174 JsonNode result = myRestClient 175 .get() 176 .uri("user-management/{nodeId}/{moduleId}?searchTerm={username}", theNodeId, theModuleId, theUsername) 177 .retrieve() 178 .body(JsonNode.class); 179 return Objects.requireNonNull(result); 180 } 181 182 /** 183 * Find users by username and return the result as a String that can be used with ObjectMapper 184 * 185 * @param theNodeId The node ID 186 * @param theModuleId The module ID 187 * @param theUsername The username to search for 188 * @return The JSON string representation of the result 189 */ 190 @Nonnull 191 public String userFindByUsernameAsString(String theNodeId, String theModuleId, String theUsername) { 192 JsonNode result = userFindByUsername(theNodeId, theModuleId, theUsername); 193 return result.toString(); 194 } 195 196 @Nonnull 197 public JsonNode userFindAll(String theNodeId, String theModuleId, int thePageSize) { 198 Validate.notEmpty(theNodeId, "Node ID is required"); 199 Validate.notEmpty(theModuleId, "Module ID is required"); 200 JsonNode result = myRestClient 201 .get() 202 .uri("user-management/{nodeId}/{moduleId}?pageSize={pageSize}", theNodeId, theModuleId, thePageSize) 203 .retrieve() 204 .body(JsonNode.class); 205 return Objects.requireNonNull(result); 206 } 207 208 /** 209 * Find all users and return the result as a String that can be used with ObjectMapper 210 * 211 * @param theNodeId The node ID 212 * @param theModuleId The module ID 213 * @param thePageSize The page size 214 * @return The JSON string representation of the result 215 */ 216 @Nonnull 217 public String userFindAllAsString(String theNodeId, String theModuleId, int thePageSize) { 218 JsonNode result = userFindAll(theNodeId, theModuleId, thePageSize); 219 return result.toString(); 220 } 221 222 @Nonnull 223 public UserDetailsJson userFetchByPid(String theNodeId, String theModuleId, Long thePid) { 224 Validate.notEmpty(theNodeId, "Node ID is required"); 225 Validate.notEmpty(theModuleId, "Module ID is required"); 226 Validate.notNull(thePid, "User does not have an assigned pid. Use userCreate() to create users."); 227 UserDetailsJson result = myRestClient 228 .get() 229 .uri("user-management/{nodeId}/{moduleId}/{userId}", theNodeId, theModuleId, thePid) 230 .retrieve() 231 .body(UserDetailsJson.class); 232 return Objects.requireNonNull(result); 233 } 234 235 /** 236 * Get the module configuration for a specific node and module 237 */ 238 @Nonnull 239 public JsonNode getModuleConfig(String theNodeId, String theModuleId) { 240 Validate.notEmpty(theNodeId, "Node ID is required"); 241 Validate.notEmpty(theModuleId, "Module ID is required"); 242 JsonNode result = myRestClient 243 .get() 244 .uri("module-config/{nodeId}/{moduleId}", theNodeId, theModuleId) 245 .retrieve() 246 .body(JsonNode.class); 247 return Objects.requireNonNull(result); 248 } 249 250 /** 251 * Update the configuration for a specific module 252 */ 253 @Nonnull 254 public JsonNode updateModuleConfig( 255 String theNodeId, 256 String theModuleId, 257 JsonNode theOptions, 258 boolean theRestart, 259 boolean theReload) { 260 Validate.notEmpty(theNodeId, "Node ID is required"); 261 Validate.notEmpty(theModuleId, "Module ID is required"); 262 Validate.notNull(theOptions, "Options is required"); 263 264 JsonNode result = 265 myRestClient.put().uri("module-config/{nodeId}/{theModuleId}/set?restart={theRestart}&reload={theReload}", theNodeId, theModuleId, theRestart, theReload).body(theOptions).retrieve().body(JsonNode.class); 266 return Objects.requireNonNull(result); 267 } 268 269 /** 270 * Request to start a module on all processes 271 */ 272 @Nonnull 273 private JsonNode startModule(String theNodeId, String theModuleId) { 274 Validate.notEmpty(theNodeId, "Node ID is required"); 275 Validate.notEmpty(theModuleId, "Module ID is required"); 276 JsonNode result = myRestClient 277 .post() 278 .uri("module-config/{nodeId}/{moduleId}/start", theNodeId, theModuleId) 279 .retrieve() 280 .body(JsonNode.class); 281 return Objects.requireNonNull(result); 282 } 283 284 /** 285 * Request to stop a module on all processes 286 * ModuleProcessesStatusChangeResponseJson 287 */ 288 @Nonnull 289 public JsonNode stopModule(String theNodeId, String theModuleId) { 290 Validate.notEmpty(theNodeId, "Node ID is required"); 291 Validate.notEmpty(theModuleId, "Module ID is required"); 292 JsonNode result = myRestClient 293 .post() 294 .uri("module-config/{nodeId}/{moduleId}/stop", theNodeId, theModuleId) 295 .retrieve() 296 .body(JsonNode.class); 297 return Objects.requireNonNull(result); 298 } 299 300 /** 301 * Request to restart a module on all processes 302 */ 303 @Nonnull 304 public JsonNode restartModule(String theNodeId, String theModuleId) { 305 Validate.notEmpty(theNodeId, "Node ID is required"); 306 Validate.notEmpty(theModuleId, "Module ID is required"); 307 JsonNode result = myRestClient 308 .post() 309 .uri("module-config/{nodeId}/{moduleId}/restart", theNodeId, theModuleId) 310 .retrieve() 311 .body(JsonNode.class); 312 return Objects.requireNonNull(result); 313 } 314 315 /** 316 * Get a list of restore points for a node 317 */ 318 @Nonnull 319 public JsonNode getRestorePoints( 320 String theNodeId, @Nullable String theVersion, @Nullable String theFromDate, @Nullable String theToDate, int theOffset, int theCount) { 321 Validate.notEmpty(theNodeId, "Node ID is required"); 322 323 JsonNode result = myRestClient.get() 324 .uri("module-config/{theNodeId}/restorePoints", builder->{ 325 builder.queryParamIfPresent("version", Optional.ofNullable(theVersion)); 326 builder.queryParamIfPresent("from", Optional.ofNullable(theFromDate)); 327 builder.queryParamIfPresent("to", Optional.ofNullable(theToDate)); 328 builder.queryParam("offset", theOffset); 329 builder.queryParam("count", theCount); 330 return builder.build(theNodeId); 331 }).retrieve().body(JsonNode.class); 332 333 return Objects.requireNonNull(result); 334 } 335 336 /** 337 * Get a specific restore point 338 */ 339 @Nonnull 340 public JsonNode getRestorePoint(String theNodeId, Long theId) { 341 Validate.notEmpty(theNodeId, "Node ID is required"); 342 Validate.notNull(theId, "Restore point ID is required"); 343 JsonNode result = myRestClient 344 .get() 345 .uri("module-config/{nodeId}/restorePoints/{id}", theNodeId, theId) 346 .retrieve() 347 .body(JsonNode.class); 348 return Objects.requireNonNull(result); 349 } 350 351 /** 352 * Restore the system to a specific restore point 353 */ 354 public void restoreSystem(String theNodeId, Long theRestorePointId) { 355 Validate.notEmpty(theNodeId, "Node ID is required"); 356 Validate.notNull(theRestorePointId, "Restore point ID is required"); 357 myRestClient 358 .post() 359 .uri("module-config/{nodeId}/restorePoints/{id}/restore", theNodeId, theRestorePointId) 360 .retrieve() 361 .toBodilessEntity(); 362 } 363 364 /** 365 * Get health checks for all modules 366 */ 367 @Nonnull 368 public JsonNode getHealthChecks(boolean theOnlyRunning) { 369 JsonNode result = myRestClient.get().uri("runtime-status/node-statuses/health-checks?onlyRunning={onlyRunning}", theOnlyRunning).retrieve().body(JsonNode.class); 370 return Objects.requireNonNull(result); 371 } 372 373 /** 374 * Get node statuses 375 */ 376 @Nonnull 377 public JsonNode getNodeStatuses() { 378 JsonNode result = myRestClient 379 .get() 380 .uri("runtime-status/node-statuses/complete") 381 .retrieve() 382 .body(JsonNode.class); 383 return Objects.requireNonNull(result); 384 } 385 386 /** 387 * Cancel a batch job 388 */ 389 public void cancelBatchJob(String theModuleId, String theJobId) { 390 Validate.notEmpty(theModuleId, "Module ID is required"); 391 Validate.notEmpty(theJobId, "Job ID is required"); 392 myRestClient 393 .get() 394 .uri("batch2-jobs/modules/{moduleId}/jobs/{jobId}/cancel", theModuleId, theJobId) 395 .retrieve() 396 .toBodilessEntity(); 397 } 398 399 /** 400 * Process a bulk ETL import file 401 */ 402 /** 403 * Process a bulk ETL import file 404 * @param theModuleId the id of the etl module to target 405 * @param theFilename the optional filename as a hint for the import process 406 * @param theContent the body of the csv file to import 407 * @return a json response 408 */ 409 public String processBulkImport(@Nonnull String theModuleId, @Nullable String theFilename, @Nonnull String theContent) { 410 return processBulkImport(theModuleId, theFilename, null, theContent); 411 } 412 413 /** 414 * Process a bulk ETL import file 415 * @param theModuleId the id of the etl module to target 416 * @param theFilename the optional filename as a hint for the import process 417 * @param theUserJobType the optional job type to use for the import process 418 * @param theContent the body of the csv file to import 419 * @return a json response 420 */ 421 public String processBulkImport(@Nonnull String theModuleId, @Nullable String theFilename, @Nullable String theUserJobType, @Nonnull String theContent) { 422 Validate.notEmpty(theModuleId, "Module ID is required"); 423 Validate.notNull(theContent, "Content is required"); 424 425 return myRestClient 426 .post() 427 .uri("bulk-import/process-etl-file/{moduleId}", builder-> { 428 builder.queryParamIfPresent("filename", Optional.ofNullable(theFilename)); 429 builder.queryParamIfPresent("userJobType", Optional.ofNullable(theUserJobType)); 430 return builder.build(Map.of("moduleId", theModuleId)); 431 }) 432 .accept(MediaType.TEXT_PLAIN) 433 .body(theContent) 434 .retrieve() 435 .body(String.class); 436 } 437 438 /** 439 * Get transaction logs 440 */ 441 @Nonnull 442 public TransactionLogEventsJson getTransactionLogs() { 443 TransactionLogEventsJson result = 444 myRestClient.get().uri("transaction-log").retrieve().body(TransactionLogEventsJson.class); 445 return Objects.requireNonNull(result); 446 } 447 448 /** 449 * Get a specific transaction log event with its body 450 */ 451 @Nonnull 452 public TransactionLogEventsJson.TransactionLogEventJson getTransactionLogEvent( 453 Long theEventId, boolean theIncludeBody) { 454 Validate.notNull(theEventId, "Event ID is required"); 455 456 TransactionLogEventsJson.TransactionLogEventJson result = 457 myRestClient.get().uri("transaction-log/event/{theEventId}?includeBody={includeBody}", theEventId, theIncludeBody).retrieve().body(TransactionLogEventsJson.TransactionLogEventJson.class); 458 return Objects.requireNonNull(result); 459 } 460 461 /** 462 * Create an OAuth client for a specific module 463 */ 464 @Nonnull 465 public OAuth2ClientDetailsJson createOAuthClient( 466 String theNodeId, String theModuleId, OAuth2WritableClientDetailsJson theClientDetails) { 467 Validate.notEmpty(theNodeId, "Node ID is required"); 468 Validate.notEmpty(theModuleId, "Module ID is required"); 469 Validate.notNull(theClientDetails, "Client details are required"); 470 471 OAuth2ClientDetailsJson result = myRestClient 472 .post() 473 .uri("openid-connect-clients/{nodeId}/{moduleId}", theNodeId, theModuleId) 474 .body(theClientDetails) 475 .retrieve() 476 .body(OAuth2ClientDetailsJson.class); 477 return Objects.requireNonNull(result); 478 } 479 480 /** 481 * Update an OAuth client for a specific module 482 */ 483 @Nonnull 484 public OAuth2ClientDetailsJson updateOAuthClient( 485 String theNodeId, 486 String theModuleId, 487 String theClientId, 488 OAuth2WritableClientDetailsJson theClientDetails) { 489 Validate.notEmpty(theNodeId, "Node ID is required"); 490 Validate.notEmpty(theModuleId, "Module ID is required"); 491 Validate.notEmpty(theClientId, "Client ID is required"); 492 Validate.notNull(theClientDetails, "Client details are required"); 493 494 OAuth2ClientDetailsJson result = myRestClient 495 .put() 496 .uri("openid-connect-clients/{nodeId}/{moduleId}/{clientId}", Map.of( 497 "nodeId", theNodeId, 498 "moduleId", theModuleId, 499 "clientId", theClientId)) 500 .body(theClientDetails) 501 .retrieve() 502 .body(OAuth2ClientDetailsJson.class); 503 return Objects.requireNonNull(result); 504 } 505 506 /** 507 * Delete an OAuth client 508 */ 509 public void deleteOAuthClient(String theNodeId, String theModuleId, String theClientId) { 510 Validate.notEmpty(theNodeId, "Node ID is required"); 511 Validate.notEmpty(theModuleId, "Module ID is required"); 512 Validate.notEmpty(theClientId, "Client ID is required"); 513 514 myRestClient 515 .delete() 516 .uri("openid-connect-clients/{nodeId}/{moduleId}/{clientId}", theNodeId, theModuleId, theClientId) 517 .retrieve() 518 .toBodilessEntity(); 519 } 520 521 /** 522 * Get an OAuth client 523 */ 524 @Nonnull 525 public OAuth2ClientDetailsJson getOAuthClient(String theNodeId, String theModuleId, String theClientId) { 526 Validate.notEmpty(theNodeId, "Node ID is required"); 527 Validate.notEmpty(theModuleId, "Module ID is required"); 528 Validate.notEmpty(theClientId, "Client ID is required"); 529 530 OAuth2ClientDetailsJson result = myRestClient 531 .get() 532 .uri("openid-connect-clients/{nodeId}/{moduleId}/{clientId}", theNodeId, theModuleId, theClientId) 533 .retrieve() 534 .body(OAuth2ClientDetailsJson.class); 535 return Objects.requireNonNull(result); 536 } 537 538 /** 539 * Create an OIDC server for a specific module 540 */ 541 @Nonnull 542 public JsonNode createOidcServer(String theNodeId, String theModuleId, JsonNode theServerDetails) { 543 Validate.notEmpty(theNodeId, "Node ID is required"); 544 Validate.notEmpty(theModuleId, "Module ID is required"); 545 Validate.notNull(theServerDetails, "Server details are required"); 546 547 JsonNode result = myRestClient 548 .post() 549 .uri("openid-connect-servers/{nodeId}/{moduleId}", theNodeId, theModuleId) 550 .body(theServerDetails) 551 .retrieve() 552 .body(JsonNode.class); 553 return Objects.requireNonNull(result); 554 } 555 556 /** 557 * Get an OIDC server 558 */ 559 @Nonnull 560 public JsonNode getOidcServer(String theNodeId, String theModuleId, String theServerId) { 561 Validate.notEmpty(theNodeId, "Node ID is required"); 562 Validate.notEmpty(theModuleId, "Module ID is required"); 563 Validate.notEmpty(theServerId, "Server ID is required"); 564 565 JsonNode result = myRestClient 566 .get() 567 .uri("openid-connect-servers/{nodeId}/{moduleId}/{serverId}", theNodeId, theModuleId, theServerId) 568 .retrieve() 569 .body(JsonNode.class); 570 return Objects.requireNonNull(result); 571 } 572 573 /** 574 * Update an OIDC server 575 */ 576 @Nonnull 577 public JsonNode updateOidcServer( 578 String theNodeId, String theModuleId, String theServerId, JsonNode theServerDetails) { 579 Validate.notEmpty(theNodeId, "Node ID is required"); 580 Validate.notEmpty(theModuleId, "Module ID is required"); 581 Validate.notEmpty(theServerId, "Server ID is required"); 582 Validate.notNull(theServerDetails, "Server details are required"); 583 584 JsonNode result = myRestClient 585 .put() 586 .uri("openid-connect-servers/{nodeId}/{moduleId}/{serverId}", theNodeId, theModuleId, theServerId) 587 .body(theServerDetails) 588 .retrieve() 589 .body(JsonNode.class); 590 return Objects.requireNonNull(result); 591 } 592 593 /** 594 * Get MDM links 595 */ 596 @Nonnull 597 public JsonNode getMdmLinks(String theModuleId) { 598 Validate.notEmpty(theModuleId, "Module ID is required"); 599 600 JsonNode result = myRestClient 601 .get() 602 .uri("mdm/{moduleId}/query-links", theModuleId) 603 .retrieve() 604 .body(JsonNode.class); 605 return Objects.requireNonNull(result); 606 } 607 608 /** 609 * Merge MDM golden resources 610 */ 611 @Nonnull 612 public JsonNode mergeMdmGoldenResources(String theModuleId, Object theRequest) { 613 Validate.notEmpty(theModuleId, "Module ID is required"); 614 Validate.notNull(theRequest, "Request is required"); 615 616 JsonNode result = myRestClient 617 .post() 618 .uri("mdm/{moduleId}/merge-golden-resources", theModuleId) 619 .body(theRequest) 620 .retrieve() 621 .body(JsonNode.class); 622 return Objects.requireNonNull(result); 623 } 624 625 /** 626 * Update MDM link 627 */ 628 @Nonnull 629 public JsonNode updateMdmLink(String theModuleId, Object theRequest) { 630 Validate.notEmpty(theModuleId, "Module ID is required"); 631 Validate.notNull(theRequest, "Request is required"); 632 633 JsonNode result = myRestClient 634 .post() 635 .uri("mdm/{moduleId}/update-link", theModuleId) 636 .body(theRequest) 637 .retrieve() 638 .body(JsonNode.class); 639 return Objects.requireNonNull(result); 640 } 641 642 /** 643 * Get MDM duplicate golden resources 644 */ 645 @Nonnull 646 public JsonNode getMdmDuplicateGoldenResources(String theModuleId) { 647 Validate.notEmpty(theModuleId, "Module ID is required"); 648 649 JsonNode result = myRestClient 650 .get() 651 .uri("mdm/{moduleId}/duplicate-golden-resources", theModuleId) 652 .retrieve() 653 .body(JsonNode.class); 654 return Objects.requireNonNull(result); 655 } 656 657 /** 658 * Submit an MDM operation to perform batch matching on all resources 659 * 660 * @param theModuleId The module ID 661 * @param theParameters The parameters for the MDM operation 662 * @return The response as a String 663 */ 664 @Nonnull 665 public String submitMdmOperation(String theModuleId, IBaseParameters theParameters) { 666 Validate.notEmpty(theModuleId, "Module ID is required"); 667 668 String body = toJsonString(theParameters); 669 670 String result = myRestClient 671 .post() 672 .uri("{moduleId}/mdm-submit", theModuleId) 673 .body(body) 674 .retrieve() 675 .body(String.class); 676 677 return result != null ? result : ""; 678 } 679 680 public static String toJsonString(IBaseResource theResource) { 681 FhirVersionEnum version = FhirVersionEnum.determineVersionForType(theResource.getClass()); 682 FhirContext ctx = FhirContext.forCached(version); 683 return ctx.newJsonParser().encodeResourceToString(theResource); 684 } 685 686 /** 687 * Exception thrown when a forbidden operation is attempted 688 */ 689 public static class ForbiddenOperationException extends RuntimeException { 690 public ForbiddenOperationException(String message, Throwable cause) { 691 super(message, cause); 692 } 693 } 694 695 /** 696 * Get the node configurations from the admin API. 697 * 698 * @return The node configurations 699 */ 700 @Nonnull 701 public NodeConfigurations getNodeConfigurations() { 702 NodeConfigurations result = myRestClient 703 .get() 704 .uri("/module-config/") 705 .retrieve() 706 .body(NodeConfigurations.class); 707 return Objects.requireNonNull(result); 708 } 709 710 /** 711 * Get the port number for a specific module from the node configurations. 712 * 713 * @param theModuleId The ID of the module to get the port for 714 * @return The port number, or the default FHIR port (8000) if not found 715 */ 716 public int getPortFromModule(String theModuleId) { 717 Validate.notEmpty(theModuleId, "Module ID is required"); 718 NodeConfigurations config = getNodeConfigurations(); 719 720 // Find the first node (assuming there's only one node in the container) 721 if (config.getNodes().isEmpty()) { 722 throw new IllegalStateException("No nodes found in node configuration"); 723 } else { 724 NodeConfigurations.NodeConfiguration node = config.getNodes().get(0); 725 726 // Find the module with the given ID 727 return node.getModule(theModuleId) 728 .map(module -> { 729 // Look for the port property in the module's configuration 730 for (NodeConfigurations.ModuleConfigProperty property : module.getConfigProperties()) { 731 if (property.getKey().equals("port")) { 732 try { 733 return Integer.parseInt(property.getValue()); 734 } catch (NumberFormatException e) { 735 // If the port is not a valid integer, fail. 736 throw new IllegalArgumentException("Invalid port number: " + property.getValue(), e); 737 } 738 } 739 } 740 // If no port property is found, return the default FHIR port 741 return null; 742 }) 743 .orElseThrow(() -> new IllegalArgumentException("Module with ID " + theModuleId + " not found in node configuration")); 744 } 745 746 } 747 748 /** 749 * 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 750 */ 751 public <T> T convertJsonNodeToModel(JsonNode jsonNode, Class<T> modelClass) throws JsonProcessingException { 752 // This is a hack to work around many of our return types being hidden in cdr-api instead of cdr-api-public. 753 // From Gary: We can't extract them cleanly yet to the fact that they often are relying on enums in clustermgr 754 // or enums in api that we do not currently want to make public, 755 // usually via 3 separate levels of nesting (e.g. node configurations -> module config -> ModuleTypeEnum 756 // TODO: Fix these, and move them to public, updating the method types to our model classes. 757 758 try { 759 ObjectMapper objectMapper = new ObjectMapper(); 760 // Direct conversion from JsonNode to model class without string conversion 761 return objectMapper.treeToValue(jsonNode, modelClass); 762 } catch (JsonProcessingException e) { 763 // Log the error with the actual JSON content for debugging 764 ourLog.error("Failed to convert JsonNode to " + modelClass.getSimpleName() + 765 ". JSON content: " + jsonNode.toString(), e); 766 throw e; 767 } 768 } 769 770 public <T> JsonNode convertModelToJsonNode(T model) throws JsonProcessingException { 771 try { 772 ObjectMapper objectMapper = new ObjectMapper(); 773 // Direct conversion from model to JsonNode without going through string 774 return objectMapper.valueToTree(model); 775 } catch (Exception e) { 776 throw new JsonProcessingException("Error converting model to JsonNode") {} ; 777 } 778 } 779 780 781 @Nullable 782 public TransactionLogEventsJson.TransactionLogEventJson getMostRecentTransactionLogEntry() { 783 TransactionLogEventsJson events = myRestClient.get().uri("/transaction-log/?pageSize=1").retrieve().body(TransactionLogEventsJson.class); 784 if (events == null || events.getEvents() == null || events.getEvents().isEmpty()) { 785 return null; 786 } else { 787 return events.getEvents().get(0); 788 } 789 } 790 791 @Nullable 792 public Long getMostRecentTransactionLogEntryId() { 793 TransactionLogEventsJson.TransactionLogEventJson entry = getMostRecentTransactionLogEntry(); 794 if (entry != null) { 795 return entry.getId(); 796 } else { 797 return null; 798 } 799 } 800 801 public void awaitNewTransactionLogEntry(Long theMostRecentTxLogEntryId) { 802 await().until(() -> getMostRecentTransactionLogEntryId(), t -> !Objects.equals(theMostRecentTxLogEntryId, t)); 803 } 804 805 /** 806 * Fetch a transaction log entry by ID and return all details (includes the body) 807 */ 808 public TransactionLogEventsJson.TransactionLogEventJson getTransactionLogEntryById(Long theId) { 809 return myRestClient.get().uri("/transaction-log/clustermgr/event/" +theId+ "?includeBody=true").retrieve().body(TransactionLogEventsJson.TransactionLogEventJson.class); 810 } 811 812}