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