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