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