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