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