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