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