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