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