001package ca.cdr.test.app.clients;
002/*-
003 * #%L
004 * Smile CDR - CDR
005 * %%
006 * Copyright (C) 2016 - 2026 Smile CDR, Inc.
007 * %%
008 * All rights reserved.
009 * #L%
010 */
011
012import ca.cdr.api.model.enm.ClientStatusFilterEnum;
013import ca.cdr.api.model.json.AuditEventsJson;
014import ca.cdr.api.model.json.CdaJson.CdaTemplateJson;
015import ca.cdr.api.model.json.CdaJson.CdaTemplateRequestJson;
016import ca.cdr.api.model.json.CdaJson.CdaTemplateResponseJson;
017import ca.cdr.api.model.json.CdaJson.CdaTemplateViewAllResponseJson;
018import ca.cdr.api.model.json.EtlImportProcessFileResponseJson;
019import ca.cdr.api.model.json.LoggerConfigJson;
020import ca.cdr.api.model.json.MdmAlgorithmsListJson;
021import ca.cdr.api.model.json.MdmMetricsJson;
022import ca.cdr.api.model.json.OAuth2ClientDetailsJson;
023import ca.cdr.api.model.json.OAuth2ClientsJson;
024import ca.cdr.api.model.json.OAuth2KeystoreJson;
025import ca.cdr.api.model.json.OAuth2KeystoresJson;
026import ca.cdr.api.model.json.OAuth2RevokeAllWithScopeResponseJson;
027import ca.cdr.api.model.json.OAuth2ServerJson;
028import ca.cdr.api.model.json.OAuth2ServersJson;
029import ca.cdr.api.model.json.OAuth2WritableClientDetailsJson;
030import ca.cdr.api.model.json.PrivacyNoticeAcceptRequestJson;
031import ca.cdr.api.model.json.PrivacyNoticeAcceptanceJson;
032import ca.cdr.api.model.json.PrivacyNoticeJson;
033import ca.cdr.api.model.json.PrivacyNoticeStatusJson;
034import ca.cdr.api.model.json.TransactionLogEventCodesJson;
035import ca.cdr.api.model.json.TransactionLogEventsJson;
036import ca.cdr.api.model.json.UserDetailsJson;
037import ca.cdr.api.model.json.batch2json.AllBatch2JobInstancesJson;
038import ca.cdr.api.model.json.batch2json.Batch2JobInstanceJson;
039import ca.cdr.api.model.json.batch2json.Batch2JobInstancesJson;
040import ca.cdr.test.app.clients.common.HttpClientOwnership;
041import ca.cdr.test.app.clients.common.RequestFactoryUtil;
042import ca.cdr.test.app.clients.common.SmileTestHttpClient;
043import ca.cdr.test.model.NodeConfigurations;
044import ca.uhn.fhir.context.FhirContext;
045import ca.uhn.fhir.context.FhirVersionEnum;
046import com.fasterxml.jackson.core.JsonProcessingException;
047import com.fasterxml.jackson.databind.JsonNode;
048import com.fasterxml.jackson.databind.ObjectMapper;
049import jakarta.annotation.Nonnull;
050import jakarta.annotation.Nullable;
051import org.apache.commons.lang3.Validate;
052import org.hl7.fhir.instance.model.api.IBaseParameters;
053import org.hl7.fhir.instance.model.api.IBaseResource;
054import org.slf4j.Logger;
055import org.slf4j.LoggerFactory;
056import org.springframework.core.ParameterizedTypeReference;
057import org.springframework.http.MediaType;
058import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
059import org.springframework.http.client.support.BasicAuthenticationInterceptor;
060import org.springframework.http.converter.ByteArrayHttpMessageConverter;
061import org.springframework.http.converter.HttpMessageConverter;
062import org.springframework.http.converter.ResourceHttpMessageConverter;
063import org.springframework.http.converter.StringHttpMessageConverter;
064import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
065import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter;
066import org.springframework.util.LinkedMultiValueMap;
067import org.springframework.util.MultiValueMap;
068import org.springframework.web.client.HttpClientErrorException;
069import org.springframework.web.client.RestClient;
070import org.springframework.web.client.RestClientException;
071
072import java.time.Duration;
073import java.util.ArrayList;
074import java.util.List;
075import java.util.Map;
076import java.util.Objects;
077import java.util.Optional;
078import java.util.Set;
079
080import static org.testcontainers.shaded.org.awaitility.Awaitility.await;
081
082// Many new methods generated by Claude Sonnet 3.7
083
084/**
085 * AdminJsonRestClient is a client for the Admin API of the CDR.
086 * It currently supports anonymous and http-basic authentication.
087 */
088public class AdminJsonRestClient implements AutoCloseable {
089        private static final Logger ourLog = LoggerFactory.getLogger(AdminJsonRestClient.class);
090
091        private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(90);
092        /**
093         * Package-private and non-final so tests can inject a smaller poll interval to exercise multi-iteration
094         * loop semantics without introducing a public setter on the production API.
095         */
096        static Duration POLL_INTERVAL = Duration.ofMillis(500);
097        private static final String TARGET_STATUS = "STARTED";
098        private static final String FAILED_TO_START = "FAILED_TO_START";
099        private static final String FAILED_TO_STOP = "FAILED_TO_STOP";
100        private static final String NOT_PRESENT = "<NOT_PRESENT>";
101        private static final String NO_PROCESSES = "<NO_PROCESSES>";
102        private static final String NEVER_OBSERVED = "<NEVER_OBSERVED>";
103
104        final RestClient myRestClient;
105
106        /**
107         * Package-private hook so tests can deterministically observe and control the poll-loop sleep
108         * (e.g. signal a latch when the worker enters the sleep branch, then perform a real interruptible
109         * {@link Thread#sleep(long)}). Production code uses {@link Thread#sleep(long)} directly.
110         */
111        @FunctionalInterface
112        interface InterruptibleSleeper {
113                void sleep(long theMillis) throws InterruptedException;
114        }
115
116        InterruptibleSleeper mySleeper = Thread::sleep;
117
118        /**
119         * The client this one issues on, and whether closing this client should release it.
120         * {@literal null} for a client built from a {@link RestClient} directly, which brought no
121         * connection pool of its own.
122         */
123        private final HttpClientOwnership myOwnership;
124
125        AdminJsonRestClient(RestClient theRestClient) {
126                this(theRestClient, null);
127        }
128
129        private AdminJsonRestClient(RestClient theRestClient, @Nullable HttpClientOwnership theOwnership) {
130                myOwnership = theOwnership;
131                myRestClient = theRestClient;
132        }
133
134        /**
135         * Opens a client authenticating as the given user, over a connection pool of its own.
136         * <p>
137         * The caller owns the pool: open this in a try-with-resources block, or close it from an
138         * {@code @AfterAll} when it is a field. Use
139         * {@link #issuingOn(SmileTestHttpClient, String, String, String)} instead where a client to
140         * issue on already exists.
141         *
142         * @param theBaseUrl the Admin JSON base URL, including port
143         * @param theUsername the user to authenticate as
144         * @param thePassword that user's password
145         */
146        public static @Nonnull AdminJsonRestClient open(
147                        @Nonnull String theBaseUrl, @Nonnull String theUsername, @Nonnull String thePassword) {
148                HttpClientOwnership ownership = HttpClientOwnership.resolve(null);
149
150                return authenticatedClient(ownership, theBaseUrl, theUsername, thePassword);
151        }
152
153        /**
154         * Opens a client sending no credentials, over a connection pool of its own.
155         * <p>
156         * The caller owns the pool ? see {@link #open(String, String, String)}.
157         *
158         * @param theBaseUrl the Admin JSON base URL, including port
159         */
160        public static @Nonnull AdminJsonRestClient openAnonymous(@Nonnull String theBaseUrl) {
161                HttpClientOwnership ownership = HttpClientOwnership.resolve(null);
162
163                return new AdminJsonRestClient(
164                        builderForUrlWithJsonDefault(theBaseUrl, ownership.client()).build(), ownership);
165        }
166
167        /**
168         * Builds a client that issues its requests on {@code theHttpClient}, sharing that client's
169         * connection pool and cookie store with everything else built over it. A harness passes its own
170         * client here so that a session established through this client is visible to the harness's
171         * other clients, and so that {@code clearCookies()} reaches this client too.
172         * <p>
173         * The pool stays the caller's: {@link #close()} leaves it open, so there is nothing here for the
174         * caller to release. Use {@link #open(String, String, String)} to build a pool of your own.
175         *
176         * @param theHttpClient the client to issue on, which the caller keeps ownership of
177         * @param theBaseUrl the Admin JSON base URL, including port
178         * @param theUsername the user to authenticate as
179         * @param thePassword that user's password
180         */
181        public static @Nonnull AdminJsonRestClient issuingOn(
182                        @Nonnull SmileTestHttpClient theHttpClient,
183                        @Nonnull String theBaseUrl,
184                        @Nonnull String theUsername,
185                        @Nonnull String thePassword) {
186                return authenticatedClient(
187                        HttpClientOwnership.resolve(theHttpClient), theBaseUrl, theUsername, thePassword);
188        }
189
190        private static @Nonnull AdminJsonRestClient authenticatedClient(
191                        @Nonnull HttpClientOwnership theOwnership,
192                        @Nonnull String theBaseUrl,
193                        @Nonnull String theUsername,
194                        @Nonnull String thePassword) {
195                RestClient restClient = builderForUrlWithJsonDefault(theBaseUrl, theOwnership.client())
196                        .requestInterceptor(new BasicAuthenticationInterceptor(theUsername, thePassword))
197                        .build();
198
199                return new AdminJsonRestClient(restClient, theOwnership);
200        }
201
202        /**
203         * @deprecated Use {@link #open(String, String, String)}, whose name says that the client owns the
204         *    connection pool it returns and that the caller has to close it.
205         */
206        @Deprecated(since = "2026.11.R01", forRemoval = true)
207        public static @Nonnull AdminJsonRestClient build(
208                        @Nonnull String theBaseUrl, @Nonnull String theUsername, @Nonnull String thePassword) {
209                return open(theBaseUrl, theUsername, thePassword);
210        }
211
212        /**
213         * @deprecated Use {@link #openAnonymous(String)}, whose name says that the client owns the
214         *    connection pool it returns and that the caller has to close it.
215         */
216        @Deprecated(since = "2026.11.R01", forRemoval = true)
217        public static @Nonnull AdminJsonRestClient buildAnonymous(@Nonnull String theBaseUrl) {
218                return openAnonymous(theBaseUrl);
219        }
220
221        private static @Nonnull RestClient.Builder builderForUrlWithJsonDefault(
222                        String theBaseUrl, @Nonnull SmileTestHttpClient theHttpClient) {
223                HttpComponentsClientHttpRequestFactory requestFactory = RequestFactoryUtil.wrap(theHttpClient);
224
225                List<HttpMessageConverter<?>> messageConverters = new ArrayList<>();
226                messageConverters.add(new ByteArrayHttpMessageConverter());
227                messageConverters.add(new StringHttpMessageConverter());
228                messageConverters.add(new ResourceHttpMessageConverter(false));
229                messageConverters.add(new AllEncompassingFormHttpMessageConverter());
230                messageConverters.add(new MappingJackson2HttpMessageConverter());
231
232                return RestClient.builder()
233                        .baseUrl(theBaseUrl)
234                        .requestFactory(requestFactory)
235                        .messageConverters(messageConverters)
236                        // This sets the default content type to JSON, but allows the actual request to override it.
237                        .defaultRequest(r -> r.accept(MediaType.APPLICATION_JSON));
238        }
239
240
241
242        @Nonnull
243        public UserDetailsJson userCreate(String theNode, String theModuleId, UserDetailsJson userDetails) {
244                Validate.notEmpty(theNode, "Node ID is required");
245                Validate.notEmpty(theModuleId, "Module ID is required");
246                Validate.notNull(userDetails, "User does not have an assigned pid.  Use userCreate() to create users.");
247                UserDetailsJson result = myRestClient
248                        .post()
249                        .uri("user-management/{nodeId}/{moduleId}", theNode, theModuleId)
250                        .body(userDetails)
251                        .retrieve()
252                        .body(UserDetailsJson.class);
253                return Objects.requireNonNull(result);
254        }
255
256        @Nonnull
257        public UserDetailsJson userUpdate(UserDetailsJson theUserDetails) {
258                Validate.notEmpty(theUserDetails.getNodeId(), "Node ID is required");
259                Validate.notEmpty(theUserDetails.getModuleId(), "Module ID is required");
260                Validate.notNull(
261                        theUserDetails.getPid(), "User does not have an assigned pid.  Use userCreate() to create users.");
262                UserDetailsJson result = myRestClient
263                        .put()
264                        .uri(
265                                "user-management/{nodeId}/{moduleId}/{userId}",
266                                theUserDetails.getNodeId(),
267                                theUserDetails.getModuleId(),
268                                theUserDetails.getPid())
269                        .body(theUserDetails)
270                        .retrieve()
271                        .body(UserDetailsJson.class);
272                return Objects.requireNonNull(result);
273        }
274
275        @Nonnull
276        public JsonNode userFindByUsername(String theNodeId, String theModuleId, String theUsername) {
277                Validate.notEmpty(theNodeId, "Node ID is required");
278                Validate.notEmpty(theModuleId, "Module ID is required");
279                Validate.notEmpty(theUsername, "username is required");
280                JsonNode result = myRestClient
281                        .get()
282                        .uri("user-management/{nodeId}/{moduleId}?searchTerm={username}", theNodeId, theModuleId, theUsername)
283                        .retrieve()
284                        .body(JsonNode.class);
285                return Objects.requireNonNull(result);
286        }
287
288        /**
289         * Find users by username and return the result as a String that can be used with ObjectMapper
290         *
291         * @param theNodeId   The node ID
292         * @param theModuleId The module ID
293         * @param theUsername The username to search for
294         * @return The JSON string representation of the result
295         */
296        @Nonnull
297        public String userFindByUsernameAsString(String theNodeId, String theModuleId, String theUsername) {
298                JsonNode result = userFindByUsername(theNodeId, theModuleId, theUsername);
299                return result.toString();
300        }
301
302        @Nonnull
303        public JsonNode userFindAll(String theNodeId, String theModuleId, int thePageSize) {
304                Validate.notEmpty(theNodeId, "Node ID is required");
305                Validate.notEmpty(theModuleId, "Module ID is required");
306                JsonNode result = myRestClient
307                        .get()
308                        .uri("user-management/{nodeId}/{moduleId}?pageSize={pageSize}", theNodeId, theModuleId, thePageSize)
309                        .retrieve()
310                        .body(JsonNode.class);
311                return Objects.requireNonNull(result);
312        }
313
314        /**
315         * Find all users and return the result as a String that can be used with ObjectMapper
316         *
317         * @param theNodeId   The node ID
318         * @param theModuleId The module ID
319         * @param thePageSize The page size
320         * @return The JSON string representation of the result
321         */
322        @Nonnull
323        public String userFindAllAsString(String theNodeId, String theModuleId, int thePageSize) {
324                JsonNode result = userFindAll(theNodeId, theModuleId, thePageSize);
325                return result.toString();
326        }
327
328        @Nonnull
329        public UserDetailsJson userFetchByPid(String theNodeId, String theModuleId, Long thePid) {
330                Validate.notEmpty(theNodeId, "Node ID is required");
331                Validate.notEmpty(theModuleId, "Module ID is required");
332                Validate.notNull(thePid, "User does not have an assigned pid.  Use userCreate() to create users.");
333                UserDetailsJson result = myRestClient
334                        .get()
335                        .uri("user-management/{nodeId}/{moduleId}/{userId}", theNodeId, theModuleId, thePid)
336                        .retrieve()
337                        .body(UserDetailsJson.class);
338                return Objects.requireNonNull(result);
339        }
340
341        /**
342         * Get the module configuration for a specific node and module
343         */
344        @Nonnull
345        public JsonNode getModuleConfig(String theNodeId, String theModuleId) {
346                Validate.notEmpty(theNodeId, "Node ID is required");
347                Validate.notEmpty(theModuleId, "Module ID is required");
348                JsonNode result = myRestClient
349                        .get()
350                        .uri("module-config/{nodeId}/{moduleId}", theNodeId, theModuleId)
351                        .retrieve()
352                        .body(JsonNode.class);
353                return Objects.requireNonNull(result);
354        }
355
356        public JsonNode getModuleInterceptors(String theNodeId, String theModuleId) {
357                Validate.notEmpty(theNodeId, "Node ID is required");
358                Validate.notEmpty(theModuleId, "Module ID is required");
359                JsonNode result = myRestClient
360                        .get()
361                        .uri("module-config/{nodeId}/{moduleId}/interceptors", theNodeId, theModuleId)
362                        .retrieve()
363                        .body(JsonNode.class);
364                return Objects.requireNonNull(result);
365        }
366
367        /**
368         * Update the configuration for a specific module. When either {@code theRestart} or {@code theReload} is set,
369         * the client blocks until the module reports {@value #TARGET_STATUS} (up to {@link #DEFAULT_TIMEOUT}).
370         * Use {@link #updateModuleConfig(String, String, JsonNode, boolean, boolean, boolean)} to opt out of the wait.
371         */
372        @Nonnull
373        public JsonNode updateModuleConfig(
374                String theNodeId,
375                String theModuleId,
376                JsonNode theOptions,
377                boolean theRestart,
378                boolean theReload) {
379                return updateModuleConfig(theNodeId, theModuleId, theOptions, theRestart, theReload, true, DEFAULT_TIMEOUT);
380        }
381
382        /**
383         * Update the configuration for a specific module.
384         *
385         * @param theShouldWaitForRestart when {@code true} and either {@code theRestart} or {@code theReload} is set,
386         *                                the client polls the admin status endpoint until the module reports
387         *                                {@value #TARGET_STATUS}, up to {@link #DEFAULT_TIMEOUT}.
388         */
389        @Nonnull
390        public JsonNode updateModuleConfig(
391                String theNodeId,
392                String theModuleId,
393                JsonNode theOptions,
394                boolean theRestart,
395                boolean theReload,
396                boolean theShouldWaitForRestart) {
397                return updateModuleConfig(theNodeId, theModuleId, theOptions, theRestart, theReload, theShouldWaitForRestart, DEFAULT_TIMEOUT);
398        }
399
400        /**
401         * Update the configuration for a specific module with a caller-supplied wait timeout.
402         *
403         * @param theShouldWaitForRestart when {@code true} and either {@code theRestart} or {@code theReload} is set,
404         *                                the client polls the admin status endpoint until the module reports
405         *                                {@value #TARGET_STATUS}, up to {@code theWaitTimeout}.
406         * @param theWaitTimeout          the maximum duration to wait for the module to report {@value #TARGET_STATUS}
407         *                                when {@code theShouldWaitForRestart} is {@code true}. Ignored otherwise.
408         */
409        @Nonnull
410        public JsonNode updateModuleConfig(
411                String theNodeId,
412                String theModuleId,
413                JsonNode theOptions,
414                boolean theRestart,
415                boolean theReload,
416                boolean theShouldWaitForRestart,
417                Duration theWaitTimeout) {
418                Validate.notEmpty(theNodeId, "Node ID is required");
419                Validate.notEmpty(theModuleId, "Module ID is required");
420                Validate.notNull(theOptions, "Options is required");
421
422                JsonNode result =
423                        myRestClient.put().uri("module-config/{nodeId}/{theModuleId}/set?restart={theRestart}&reload={theReload}", theNodeId, theModuleId, theRestart, theReload).body(theOptions).retrieve().body(JsonNode.class);
424
425                if (theShouldWaitForRestart && (theRestart || theReload)) {
426                        Validate.notNull(theWaitTimeout, "Wait timeout is required");
427                        waitForModuleStarted(theNodeId, theModuleId, theWaitTimeout);
428                }
429
430                return Objects.requireNonNull(result);
431        }
432
433        /**
434         * Request to start a module on all processes
435         */
436        @Nonnull
437        private JsonNode startModule(String theNodeId, String theModuleId) {
438                Validate.notEmpty(theNodeId, "Node ID is required");
439                Validate.notEmpty(theModuleId, "Module ID is required");
440                JsonNode result = myRestClient
441                        .post()
442                        .uri("module-config/{nodeId}/{moduleId}/start", theNodeId, theModuleId)
443                        .retrieve()
444                        .body(JsonNode.class);
445                return Objects.requireNonNull(result);
446        }
447
448        /**
449         * Request to stop a module on all processes
450         * ModuleProcessesStatusChangeResponseJson
451         */
452        @Nonnull
453        public JsonNode stopModule(String theNodeId, String theModuleId) {
454                Validate.notEmpty(theNodeId, "Node ID is required");
455                Validate.notEmpty(theModuleId, "Module ID is required");
456                JsonNode result = myRestClient
457                        .post()
458                        .uri("module-config/{nodeId}/{moduleId}/stop", theNodeId, theModuleId)
459                        .retrieve()
460                        .body(JsonNode.class);
461                return Objects.requireNonNull(result);
462        }
463
464        /**
465         * Request to restart a module on all processes. Blocks until the module reports {@value #TARGET_STATUS}
466         * (up to {@link #DEFAULT_TIMEOUT}). Use {@link #restartModule(String, String, boolean)} to opt out of the wait.
467         */
468        @Nonnull
469        public JsonNode restartModule(String theNodeId, String theModuleId) {
470                return restartModule(theNodeId, theModuleId, true, DEFAULT_TIMEOUT);
471        }
472
473        /**
474         * Request to restart a module on all processes.
475         *
476         * @param theShouldWaitForRestart when {@code true}, the client polls the admin status endpoint
477         *                                until the module reports {@value #TARGET_STATUS},
478         *                                up to {@link #DEFAULT_TIMEOUT}.
479         */
480        @Nonnull
481        public JsonNode restartModule(String theNodeId, String theModuleId, boolean theShouldWaitForRestart) {
482                return restartModule(theNodeId, theModuleId, theShouldWaitForRestart, DEFAULT_TIMEOUT);
483        }
484
485        /**
486         * Request to restart a module on all processes with a caller-supplied wait timeout.
487         *
488         * @param theShouldWaitForRestart when {@code true}, the client polls the admin status endpoint
489         *                                until the module reports {@value #TARGET_STATUS},
490         *                                up to {@code theWaitTimeout}.
491         * @param theWaitTimeout          the maximum duration to wait for the module to report {@value #TARGET_STATUS}
492         *                                when {@code theShouldWaitForRestart} is {@code true}. Ignored otherwise.
493         */
494        @Nonnull
495        public JsonNode restartModule(String theNodeId, String theModuleId, boolean theShouldWaitForRestart, Duration theWaitTimeout) {
496                Validate.notEmpty(theNodeId, "Node ID is required");
497                Validate.notEmpty(theModuleId, "Module ID is required");
498                JsonNode result = myRestClient
499                        .post()
500                        .uri("module-config/{nodeId}/{moduleId}/restart", theNodeId, theModuleId)
501                        .retrieve()
502                        .body(JsonNode.class);
503
504                if (theShouldWaitForRestart) {
505                        Validate.notNull(theWaitTimeout, "Wait timeout is required");
506                        waitForModuleStarted(theNodeId, theModuleId, theWaitTimeout);
507                }
508
509                return Objects.requireNonNull(result);
510        }
511
512        /**
513         * Package-private overload that accepts a custom timeout. Primarily intended for tests that need to
514         * exercise the timeout branch without waiting 90 seconds.
515         *
516         * Polls {@link #getNodeStatuses()} until the first process of the given module on the given node reports
517         * {@value #TARGET_STATUS}, up to {@code theTimeout}. Throws {@link IllegalStateException} on timeout
518         * or terminal failure ({@value #FAILED_TO_START} / {@value #FAILED_TO_STOP}).
519         */
520        void waitForModuleStarted(String theNodeId, String theModuleId, Duration theTimeout) {
521                Validate.notEmpty(theNodeId, "Node ID is required");
522                Validate.notEmpty(theModuleId, "Module ID is required");
523                Validate.notNull(theTimeout, "Timeout is required");
524
525                long deadlineNanos = System.nanoTime() + theTimeout.toNanos();
526                int completedProbes = 0;
527                String lastObservedStatus = NEVER_OBSERVED;
528                RuntimeException lastException = null;
529                int transientErrorCount = 0;
530
531                while (true) {
532                        ModuleStatusProbe probe = null;
533                        try {
534                                JsonNode statuses = getNodeStatuses();
535                                probe = extractModuleProcessStatus(statuses, theNodeId, theModuleId);
536                                completedProbes++;
537                        } catch (RestClientException e) {
538                                // Narrow catch: only Spring HTTP-layer transients (RestClientException covers ResourceAccessException,
539                                // HttpServerErrorException, HttpClientErrorException, etc.). Programmer errors like NPE, IAE, ISE
540                                // from extractModuleProcessStatus or elsewhere propagate up immediately so they aren't masked
541                                // by a 90s timeout.
542                                lastException = e;
543                                transientErrorCount++;
544                                ourLog.debug("Transient error while polling node statuses for module '{}' on node '{}' (transient #{}): {}",
545                                        theModuleId, theNodeId, transientErrorCount, e.toString());
546                        }
547
548                        if (probe != null) {
549                                lastObservedStatus = probe.presence();
550                                if (probe.rawStatus() != null) {
551                                        if (TARGET_STATUS.equals(probe.rawStatus())) {
552                                                ourLog.debug("Module '{}' on node '{}' reported {} after {} completed probe(s)",
553                                                        theModuleId, theNodeId, TARGET_STATUS, completedProbes);
554                                                return;
555                                        }
556                                        if (probe.terminalFailure()) {
557                                                // Throw terminal-failure OUTSIDE the try/catch above so it is never classified as transient.
558                                                throw new IllegalStateException(String.format(
559                                                        "Module '%s' on node '%s' reached terminal failure status '%s' after %d completed probe(s)",
560                                                        theModuleId, theNodeId, probe.rawStatus(), completedProbes));
561                                        }
562                                }
563                        }
564
565                        if (System.nanoTime() >= deadlineNanos) {
566                                String message = String.format(
567                                        "Timed out after %d ms waiting for module '%s' on node '%s' to reach status '%s' (last observed status: %s, poll attempts: %d, transient errors: %d)",
568                                        theTimeout.toMillis(), theModuleId, theNodeId, TARGET_STATUS, lastObservedStatus, completedProbes, transientErrorCount);
569                                if (lastException != null) {
570                                        // Use the FQN so subclasses (e.g. HttpServerErrorException$InternalServerError) still surface
571                                        // the parent class name in the timeout message.
572                                        String causeSummary = lastException.getClass().getName() + ": " + lastException.getMessage();
573                                        throw new IllegalStateException(message + "; last error: " + causeSummary, lastException);
574                                }
575                                throw new IllegalStateException(message);
576                        }
577
578                        try {
579                                mySleeper.sleep(POLL_INTERVAL.toMillis());
580                        } catch (InterruptedException e) {
581                                Thread.currentThread().interrupt();
582                                throw new IllegalStateException(
583                                        "Interrupted while waiting for module '" + theModuleId + "' on node '" + theNodeId + "' to start",
584                                        e);
585                        }
586                }
587        }
588
589        /**
590         * Result of probing the {@code node-statuses/complete} payload for a specific node/module.
591         *
592         * <p>The record stores only {@code rawStatus} and (when {@code rawStatus} is {@code null}) an
593         * {@code absenceLabel} that explains why no status was observed ({@value #NOT_PRESENT} or
594         * {@value #NO_PROCESSES}). The observability-friendly {@link #presence()} label and the
595         * {@link #terminalFailure()} flag are derived from {@code rawStatus} so there is a single source
596         * of truth and no two fields to keep in sync.
597         *
598         * @param rawStatus    the raw {@code processes[0].status} string, or {@code null} when the module row was
599         *                     absent, the processes array was empty, or the status field was missing.
600         * @param absenceLabel when {@code rawStatus} is {@code null}, one of {@value #NOT_PRESENT} or
601         *                     {@value #NO_PROCESSES}. Ignored (and may be {@code null}) when {@code rawStatus}
602         *                     is non-null.
603         */
604        private record ModuleStatusProbe(@Nullable String rawStatus, @Nullable String absenceLabel) {
605                boolean terminalFailure() {
606                        return FAILED_TO_START.equals(rawStatus) || FAILED_TO_STOP.equals(rawStatus);
607                }
608
609                String presence() {
610                        return rawStatus != null ? rawStatus : absenceLabel;
611                }
612        }
613
614        /**
615         * Walk the raw {@code node-statuses/complete} payload to extract the first process status for the given
616         * {@code nodeId} / {@code moduleId}.
617         *
618         * Expected shape: {@code { "nodes": [ { "nodeId": "...", "modules": [ { "moduleId": "...", "processes": [ { "status": "..." } ] } ] } ] } }.
619         */
620        @Nonnull
621        private static ModuleStatusProbe extractModuleProcessStatus(JsonNode theStatuses, String theNodeId, String theModuleId) {
622                if (theStatuses == null) {
623                        return new ModuleStatusProbe(null, NOT_PRESENT);
624                }
625                JsonNode nodes = theStatuses.get("nodes");
626                if (nodes == null || !nodes.isArray()) {
627                        return new ModuleStatusProbe(null, NOT_PRESENT);
628                }
629                for (JsonNode node : nodes) {
630                        JsonNode nodeId = node.get("nodeId");
631                        if (nodeId == null || !theNodeId.equals(nodeId.asText())) {
632                                continue;
633                        }
634                        JsonNode modules = node.get("modules");
635                        if (modules == null || !modules.isArray()) {
636                                continue;
637                        }
638                        for (JsonNode module : modules) {
639                                JsonNode moduleId = module.get("moduleId");
640                                if (moduleId == null || !theModuleId.equals(moduleId.asText())) {
641                                        continue;
642                                }
643                                JsonNode processes = module.get("processes");
644                                if (processes == null || !processes.isArray() || processes.isEmpty()) {
645                                        // Node+module matched, but no processes are reporting yet.
646                                        return new ModuleStatusProbe(null, NO_PROCESSES);
647                                }
648                                JsonNode status = processes.get(0).get("status");
649                                if (status == null) {
650                                        return new ModuleStatusProbe(null, NO_PROCESSES);
651                                }
652                                return new ModuleStatusProbe(status.asText(), null);
653                        }
654                }
655                return new ModuleStatusProbe(null, NOT_PRESENT);
656        }
657
658        /**
659         * Get a list of restore points for a node
660         */
661        @Nonnull
662        public JsonNode getRestorePoints(
663                String theNodeId, @Nullable String theVersion, @Nullable String theFromDate, @Nullable String theToDate, int theOffset, int theCount) {
664                Validate.notEmpty(theNodeId, "Node ID is required");
665
666                JsonNode result = myRestClient.get()
667                        .uri("module-config/{theNodeId}/restorePoints", builder->{
668                                builder.queryParamIfPresent("version", Optional.ofNullable(theVersion));
669                                builder.queryParamIfPresent("from", Optional.ofNullable(theFromDate));
670                                builder.queryParamIfPresent("to", Optional.ofNullable(theToDate));
671                                builder.queryParam("offset", theOffset);
672                                builder.queryParam("count", theCount);
673                                return builder.build(theNodeId);
674                        }).retrieve().body(JsonNode.class);
675
676                return Objects.requireNonNull(result);
677        }
678
679        /**
680         * Get a specific restore point
681         */
682        @Nonnull
683        public JsonNode getRestorePoint(String theNodeId, Long theId) {
684                Validate.notEmpty(theNodeId, "Node ID is required");
685                Validate.notNull(theId, "Restore point ID is required");
686                JsonNode result = myRestClient
687                        .get()
688                        .uri("module-config/{nodeId}/restorePoints/{id}", theNodeId, theId)
689                        .retrieve()
690                        .body(JsonNode.class);
691                return Objects.requireNonNull(result);
692        }
693
694        /**
695         * Restore the system to a specific restore point
696         */
697        public void restoreSystem(String theNodeId, Long theRestorePointId) {
698                Validate.notEmpty(theNodeId, "Node ID is required");
699                Validate.notNull(theRestorePointId, "Restore point ID is required");
700                myRestClient
701                        .post()
702                        .uri("module-config/{nodeId}/restorePoints/{id}/restore", theNodeId, theRestorePointId)
703                        .retrieve()
704                        .toBodilessEntity();
705        }
706
707        /**
708         * Get health checks for all modules
709         */
710        @Nonnull
711        public JsonNode getHealthChecks(boolean theOnlyRunning) {
712                JsonNode result = myRestClient.get().uri("runtime-status/node-statuses/health-checks?onlyRunning={onlyRunning}", theOnlyRunning).retrieve().body(JsonNode.class);
713                return Objects.requireNonNull(result);
714        }
715
716        /**
717         * Get node statuses
718         */
719        @Nonnull
720        public JsonNode getNodeStatuses() {
721                JsonNode result = myRestClient
722                        .get()
723                        .uri("runtime-status/node-statuses/complete")
724                        .retrieve()
725                        .body(JsonNode.class);
726                return Objects.requireNonNull(result);
727        }
728
729        /**
730         * Cancel a batch job
731         */
732        public void cancelBatchJob(String theModuleId, String theJobId) {
733                Validate.notEmpty(theModuleId, "Module ID is required");
734                Validate.notEmpty(theJobId, "Job ID is required");
735                myRestClient
736                        .get()
737                        .uri("batch2-jobs/modules/{moduleId}/jobs/{jobId}/cancel", theModuleId, theJobId)
738                        .retrieve()
739                        .toBodilessEntity();
740        }
741
742        /**
743         * Get all module IDs that support batch jobs
744         */
745        @Nonnull
746        public String[] getAllBatchJobModuleIds() {
747                String[] result = myRestClient
748                        .get()
749                        .uri("batch2-jobs/modules")
750                        .retrieve()
751                        .body(String[].class);
752                return Objects.requireNonNull(result);
753        }
754
755        /**
756         * Get a specific batch job instance by ID
757         */
758        @Nonnull
759        public Batch2JobInstanceJson getBatchJobInstance(String theModuleId, String theJobId) {
760                Validate.notEmpty(theModuleId, "Module ID is required");
761                Validate.notEmpty(theJobId, "Job ID is required");
762                Batch2JobInstanceJson result = myRestClient
763                        .get()
764                        .uri("batch2-jobs/modules/{moduleId}/jobs/{jobId}", theModuleId, theJobId)
765                        .retrieve()
766                        .body(Batch2JobInstanceJson.class);
767                return Objects.requireNonNull(result);
768        }
769
770        @Nonnull
771        public AllBatch2JobInstancesJson getBatchJobInstances(String theModuleId) {
772                return getBatchJobInstances(theModuleId, new LinkedMultiValueMap<>());
773        }
774
775        @Nonnull
776        public AllBatch2JobInstancesJson getBatchJobInstances(
777                String theModuleId, @Nonnull MultiValueMap<String, String> theQueryParams) {
778                Validate.notEmpty(theModuleId, "Module ID is required");
779
780                AllBatch2JobInstancesJson result = myRestClient
781                        .get()
782                        .uri("batch2-jobs/modules/{moduleId}", builder -> builder.queryParams(theQueryParams).build(theModuleId))
783                        .retrieve()
784                        .body(AllBatch2JobInstancesJson.class);
785                return Objects.requireNonNull(result);
786        }
787
788        @Nonnull
789        public Batch2JobInstancesJson getBatchJobInstancesByName(
790                String theModuleId, String theJobDefinitionId, @Nullable Boolean theEnded, int theStart, int theCount) {
791                Validate.notEmpty(theModuleId, "Module ID is required");
792                Validate.notEmpty(theJobDefinitionId, "Job definition ID is required");
793
794                Batch2JobInstancesJson result = myRestClient
795                        .get()
796                        .uri("batch2-jobs/modules/{moduleId}/names/{name}/jobs", builder -> {
797                                builder.queryParam("start", theStart);
798                                builder.queryParam("count", theCount);
799                                builder.queryParamIfPresent("ended", Optional.ofNullable(theEnded));
800                                return builder.build(theModuleId, theJobDefinitionId);
801                        })
802                        .retrieve()
803                        .body(Batch2JobInstancesJson.class);
804                return Objects.requireNonNull(result);
805        }
806
807        /**
808         * Process a bulk ETL import file
809         * @param theModuleId the id of the etl module to target
810         * @param theFilename the optional filename as a hint for the import process
811         * @param theContent the body of the csv file to import
812         */
813        @Nonnull
814        public EtlImportProcessFileResponseJson processBulkImport(@Nonnull String theModuleId, @Nullable String theFilename, @Nonnull String theContent) {
815                return processBulkImport(theModuleId, theFilename, null, theContent);
816        }
817
818        /**
819         * Process a bulk ETL import file
820         * @param theModuleId the id of the etl module to target
821         * @param theFilename the optional filename as a hint for the import process
822         * @param theUserJobType the optional job type to use for the import process
823         * @param theContent the body of the csv file to import
824         */
825        @Nonnull
826        public EtlImportProcessFileResponseJson processBulkImport(@Nonnull String theModuleId, @Nullable String theFilename, @Nullable String theUserJobType, @Nonnull String theContent) {
827                Validate.notEmpty(theModuleId, "Module ID is required");
828                Validate.notNull(theContent, "Content is required");
829
830                EtlImportProcessFileResponseJson result = myRestClient
831                        .post()
832                        .uri("bulk-import/process-etl-file/{moduleId}", builder-> {
833                                builder.queryParamIfPresent("filename", Optional.ofNullable(theFilename));
834                                builder.queryParamIfPresent("userJobType", Optional.ofNullable(theUserJobType));
835                                return builder.build(Map.of("moduleId", theModuleId));
836                        })
837                        .accept(MediaType.APPLICATION_JSON)
838                        .body(theContent)
839                        .retrieve()
840                        .body(EtlImportProcessFileResponseJson.class);
841                return Objects.requireNonNull(result);
842        }
843
844        /**
845         * Get transaction logs
846         */
847        @Nonnull
848        public TransactionLogEventsJson getTransactionLogs() {
849                TransactionLogEventsJson result =
850                        myRestClient.get().uri("transaction-log").retrieve().body(TransactionLogEventsJson.class);
851                return Objects.requireNonNull(result);
852        }
853
854        @Nonnull
855        public TransactionLogEventsJson getTransactionLogs(@Nonnull MultiValueMap<String, String> theQueryParams) {
856                TransactionLogEventsJson result = myRestClient
857                        .get()
858                        .uri("transaction-log", builder -> builder.queryParams(theQueryParams).build())
859                        .retrieve()
860                        .body(TransactionLogEventsJson.class);
861                return Objects.requireNonNull(result);
862        }
863
864        @Nonnull
865        public TransactionLogEventCodesJson getTransactionLogEventCodes() {
866                TransactionLogEventCodesJson result = myRestClient
867                        .get()
868                        .uri("transaction-log/event-codes")
869                        .retrieve()
870                        .body(TransactionLogEventCodesJson.class);
871                return Objects.requireNonNull(result);
872        }
873
874        @Nonnull
875        public AuditEventsJson getAuditEvents() {
876                return getAuditEvents(new LinkedMultiValueMap<>());
877        }
878
879        @Nonnull
880        public AuditEventsJson getAuditEvents(@Nonnull MultiValueMap<String, String> theQueryParams) {
881                AuditEventsJson result = myRestClient
882                        .get()
883                        .uri("audit-log", builder -> builder.queryParams(theQueryParams).build())
884                        .retrieve()
885                        .body(AuditEventsJson.class);
886                return Objects.requireNonNull(result);
887        }
888
889        @Nonnull
890        public MdmAlgorithmsListJson getMdmAlgorithms(String theModuleId) {
891                Validate.notEmpty(theModuleId, "Module ID is required");
892
893                MdmAlgorithmsListJson result = myRestClient
894                        .get()
895                        .uri("mdm/{moduleId}/mdm-algorithms", theModuleId)
896                        .retrieve()
897                        .body(MdmAlgorithmsListJson.class);
898                return Objects.requireNonNull(result);
899        }
900
901        @Nonnull
902        public MdmMetricsJson getMdmMetrics(String theModuleId, String theResourceType) {
903                Validate.notEmpty(theModuleId, "Module ID is required");
904                Validate.notEmpty(theResourceType, "Resource type is required");
905
906                MdmMetricsJson result = myRestClient
907                        .get()
908                        .uri("mdm/{moduleId}/mdm-metrics?resourceType={resourceType}", theModuleId, theResourceType)
909                        .retrieve()
910                        .body(MdmMetricsJson.class);
911                return Objects.requireNonNull(result);
912        }
913
914        @Nonnull
915        public Set<LoggerConfigJson> getTroubleshootingLoggers() {
916                Set<LoggerConfigJson> result = myRestClient
917                        .get()
918                        .uri("troubleshooting-log/configuration")
919                        .retrieve()
920                        .body(new ParameterizedTypeReference<>() {});
921                return Objects.requireNonNull(result);
922        }
923
924        @Nonnull
925        public Set<LoggerConfigJson> updateTroubleshootingLoggers(@Nonnull Set<LoggerConfigJson> theLoggers) {
926                Set<LoggerConfigJson> result = myRestClient
927                        .post()
928                        .uri("troubleshooting-log/configuration")
929                        .contentType(MediaType.APPLICATION_JSON)
930                        .body(theLoggers)
931                        .retrieve()
932                        .body(new ParameterizedTypeReference<>() {});
933                return Objects.requireNonNull(result);
934        }
935
936        @Nonnull
937        public LoggerConfigJson updateTroubleshootingLoggerLevel(String theLoggerName, String theLoggerLevel) {
938                Validate.notEmpty(theLoggerName, "Logger name is required");
939                Validate.notEmpty(theLoggerLevel, "Logger level is required");
940
941                LoggerConfigJson result = myRestClient
942                        .put()
943                        .uri("troubleshooting-log/configuration/{loggerName}?loggerLevel={loggerLevel}", theLoggerName, theLoggerLevel)
944                        .retrieve()
945                        .body(LoggerConfigJson.class);
946                return Objects.requireNonNull(result);
947        }
948
949        /**
950         * Get a specific transaction log event with its body
951         */
952        @Nonnull
953        public TransactionLogEventsJson.TransactionLogEventJson getTransactionLogEvent(
954                Long theEventId, boolean theIncludeBody) {
955                Validate.notNull(theEventId, "Event ID is required");
956
957                TransactionLogEventsJson.TransactionLogEventJson result =
958                        myRestClient.get().uri("transaction-log/event/{theEventId}?includeBody={includeBody}", theEventId, theIncludeBody).retrieve().body(TransactionLogEventsJson.TransactionLogEventJson.class);
959                return Objects.requireNonNull(result);
960        }
961
962        /**
963         * Create an OAuth client for a specific module
964         */
965        @Nonnull
966        public OAuth2ClientDetailsJson createOAuthClient(
967                String theNodeId, String theModuleId, OAuth2WritableClientDetailsJson theClientDetails) {
968                Validate.notEmpty(theNodeId, "Node ID is required");
969                Validate.notEmpty(theModuleId, "Module ID is required");
970                Validate.notEmpty(theClientDetails.getClientId(), "Client ID is required");
971                Validate.notNull(theClientDetails, "Client details are required");
972
973                OAuth2ClientDetailsJson result;
974                try {
975                        result = myRestClient
976                                .post()
977                                .uri("openid-connect-clients/{nodeId}/{moduleId}", theNodeId, theModuleId)
978                                .body(theClientDetails)
979                                .retrieve()
980                                .body(OAuth2ClientDetailsJson.class);
981                } catch (HttpClientErrorException e) {
982                        ourLog.warn("Failed to create OIDC client, going to try update instead: " + e.toString());
983                        result = myRestClient
984                                .post()
985                                .uri("openid-connect-clients/{nodeId}/{moduleId}/{clientId}", theNodeId, theModuleId, theClientDetails.getClientId())
986                                .body(theClientDetails)
987                                .retrieve()
988                                .body(OAuth2ClientDetailsJson.class);
989                }
990                return Objects.requireNonNull(result);
991        }
992
993        /**
994         * Update an OAuth client for a specific module
995         */
996        @Nonnull
997        public OAuth2ClientDetailsJson updateOAuthClient(
998                String theNodeId,
999                String theModuleId,
1000                String theClientId,
1001                OAuth2WritableClientDetailsJson theClientDetails) {
1002                Validate.notEmpty(theNodeId, "Node ID is required");
1003                Validate.notEmpty(theModuleId, "Module ID is required");
1004                Validate.notEmpty(theClientId, "Client ID is required");
1005                Validate.notNull(theClientDetails, "Client details are required");
1006
1007                OAuth2ClientDetailsJson result = myRestClient
1008                        .put()
1009                        .uri("openid-connect-clients/{nodeId}/{moduleId}/{clientId}", Map.of(
1010                                "nodeId", theNodeId,
1011                                "moduleId", theModuleId,
1012                                "clientId", theClientId))
1013                        .body(theClientDetails)
1014                        .retrieve()
1015                        .body(OAuth2ClientDetailsJson.class);
1016                return Objects.requireNonNull(result);
1017        }
1018
1019        /**
1020         * Delete an OAuth client
1021         */
1022        public void deleteOAuthClient(String theNodeId, String theModuleId, String theClientId) {
1023                Validate.notEmpty(theNodeId, "Node ID is required");
1024                Validate.notEmpty(theModuleId, "Module ID is required");
1025                Validate.notEmpty(theClientId, "Client ID is required");
1026
1027                myRestClient
1028                        .delete()
1029                        .uri("openid-connect-clients/{nodeId}/{moduleId}/{clientId}", theNodeId, theModuleId, theClientId)
1030                        .retrieve()
1031                        .toBodilessEntity();
1032        }
1033
1034        @Nonnull
1035        public OAuth2ClientsJson getOAuthClients(int thePageIndex, int thePageSize, @Nullable ClientStatusFilterEnum theClientStatus) {
1036                OAuth2ClientsJson result = myRestClient
1037                        .get()
1038                        .uri("openid-connect-clients", builder -> {
1039                                builder.queryParam("pageIndex", thePageIndex);
1040                                builder.queryParam("pageSize", thePageSize);
1041                                builder.queryParamIfPresent("clientStatus", Optional.ofNullable(theClientStatus));
1042                                return builder.build();
1043                        })
1044                        .retrieve()
1045                        .body(OAuth2ClientsJson.class);
1046                return Objects.requireNonNull(result);
1047        }
1048
1049        /**
1050         * Get an OAuth client
1051         */
1052        @Nonnull
1053        public OAuth2ClientDetailsJson getOAuthClient(String theNodeId, String theModuleId, String theClientId) {
1054                Validate.notEmpty(theNodeId, "Node ID is required");
1055                Validate.notEmpty(theModuleId, "Module ID is required");
1056                Validate.notEmpty(theClientId, "Client ID is required");
1057
1058                OAuth2ClientDetailsJson result = myRestClient
1059                        .get()
1060                        .uri("openid-connect-clients/{nodeId}/{moduleId}/{clientId}", theNodeId, theModuleId, theClientId)
1061                        .retrieve()
1062                        .body(OAuth2ClientDetailsJson.class);
1063                return Objects.requireNonNull(result);
1064        }
1065
1066        /**
1067         * Create an OIDC server for a specific module
1068         */
1069        @Nonnull
1070        public JsonNode createOidcServer(String theNodeId, String theModuleId, JsonNode theServerDetails) {
1071                Validate.notEmpty(theNodeId, "Node ID is required");
1072                Validate.notEmpty(theModuleId, "Module ID is required");
1073                Validate.notNull(theServerDetails, "Server details are required");
1074
1075                JsonNode result = myRestClient
1076                        .post()
1077                        .uri("openid-connect-servers/{nodeId}/{moduleId}", theNodeId, theModuleId)
1078                        .body(theServerDetails)
1079                        .retrieve()
1080                        .body(JsonNode.class);
1081                return Objects.requireNonNull(result);
1082        }
1083
1084        @Nonnull
1085        public OAuth2ServerJson createOidcServer(String theNodeId, String theModuleId, @Nonnull OAuth2ServerJson theServer) {
1086                Validate.notEmpty(theNodeId, "Node ID is required");
1087                Validate.notEmpty(theModuleId, "Module ID is required");
1088                Validate.notNull(theServer, "Server is required");
1089
1090                OAuth2ServerJson result = myRestClient
1091                        .post()
1092                        .uri("openid-connect-servers/{nodeId}/{moduleId}", theNodeId, theModuleId)
1093                        .contentType(MediaType.APPLICATION_JSON)
1094                        .body(theServer)
1095                        .retrieve()
1096                        .body(OAuth2ServerJson.class);
1097                return Objects.requireNonNull(result);
1098        }
1099
1100        @Nonnull
1101        public OAuth2ServerJson getOidcServer(String theNodeId, String theModuleId, String theIssuerUrl) {
1102                Validate.notEmpty(theNodeId, "Node ID is required");
1103                Validate.notEmpty(theModuleId, "Module ID is required");
1104                Validate.notEmpty(theIssuerUrl, "Issuer URL is required");
1105
1106                OAuth2ServerJson result = myRestClient
1107                        .get()
1108                        .uri("openid-connect-servers/{nodeId}/{moduleId}?issuer_url={issuerUrl}", theNodeId, theModuleId, theIssuerUrl)
1109                        .retrieve()
1110                        .body(OAuth2ServerJson.class);
1111                return Objects.requireNonNull(result);
1112        }
1113
1114        @Nonnull
1115        public OAuth2ServersJson getOidcServers(
1116                @Nullable String theNodeId, @Nullable String theModuleId, int thePageIndex, int thePageSize) {
1117                OAuth2ServersJson result = myRestClient
1118                        .get()
1119                        .uri("openid-connect-servers", builder -> {
1120                                builder.queryParamIfPresent("node_id", Optional.ofNullable(theNodeId));
1121                                builder.queryParamIfPresent("module_id", Optional.ofNullable(theModuleId));
1122                                builder.queryParam("pageIndex", thePageIndex);
1123                                builder.queryParam("pageSize", thePageSize);
1124                                return builder.build();
1125                        })
1126                        .retrieve()
1127                        .body(OAuth2ServersJson.class);
1128                return Objects.requireNonNull(result);
1129        }
1130
1131        /**
1132         * Update an OIDC server
1133         */
1134        @Nonnull
1135        public JsonNode updateOidcServer(
1136                String theNodeId, String theModuleId, String theServerId, JsonNode theServerDetails) {
1137                Validate.notEmpty(theNodeId, "Node ID is required");
1138                Validate.notEmpty(theModuleId, "Module ID is required");
1139                Validate.notEmpty(theServerId, "Server ID is required");
1140                Validate.notNull(theServerDetails, "Server details are required");
1141
1142                JsonNode result = myRestClient
1143                        .put()
1144                        .uri("openid-connect-servers/{nodeId}/{moduleId}/{serverId}", theNodeId, theModuleId, theServerId)
1145                        .body(theServerDetails)
1146                        .retrieve()
1147                        .body(JsonNode.class);
1148                return Objects.requireNonNull(result);
1149        }
1150
1151        @Nonnull
1152        public OAuth2ServerJson updateOidcServer(String theNodeId, String theModuleId, @Nonnull OAuth2ServerJson theServer) {
1153                Validate.notEmpty(theNodeId, "Node ID is required");
1154                Validate.notEmpty(theModuleId, "Module ID is required");
1155                Validate.notNull(theServer, "Server is required");
1156                Validate.notNull(theServer.getPid(), "Server PID is required");
1157
1158                OAuth2ServerJson result = myRestClient
1159                        .put()
1160                        .uri("openid-connect-servers/{nodeId}/{moduleId}/{pid}", theNodeId, theModuleId, theServer.getPid())
1161                        .contentType(MediaType.APPLICATION_JSON)
1162                        .body(theServer)
1163                        .retrieve()
1164                        .body(OAuth2ServerJson.class);
1165                return Objects.requireNonNull(result);
1166        }
1167
1168        @Nonnull
1169        public CdaTemplateJson createCdaTemplate(
1170                String theModuleId, String theTemplateId, @Nonnull CdaTemplateRequestJson theTemplate) {
1171                return writeCdaTemplate(myRestClient.post(), theModuleId, theTemplateId, theTemplate);
1172        }
1173
1174        @Nonnull
1175        public CdaTemplateJson updateCdaTemplate(
1176                String theModuleId, String theTemplateId, @Nonnull CdaTemplateRequestJson theTemplate) {
1177                return writeCdaTemplate(myRestClient.put(), theModuleId, theTemplateId, theTemplate);
1178        }
1179
1180        private CdaTemplateJson writeCdaTemplate(
1181                RestClient.RequestBodyUriSpec theMethod,
1182                String theModuleId,
1183                String theTemplateId,
1184                CdaTemplateRequestJson theTemplate) {
1185                Validate.notEmpty(theModuleId, "Module ID is required");
1186                Validate.notEmpty(theTemplateId, "Template ID is required");
1187                Validate.notNull(theTemplate, "Template is required");
1188
1189                CdaTemplateJson result = theMethod
1190                        .uri("cda/{moduleId}/template/{templateId}", theModuleId, theTemplateId)
1191                        .contentType(MediaType.APPLICATION_JSON)
1192                        .body(theTemplate)
1193                        .retrieve()
1194                        .body(CdaTemplateJson.class);
1195                return Objects.requireNonNull(result);
1196        }
1197
1198        @Nonnull
1199        public CdaTemplateJson updateCdaTemplateScript(String theModuleId, String theTemplateId, String theScript) {
1200                Validate.notEmpty(theModuleId, "Module ID is required");
1201                Validate.notEmpty(theTemplateId, "Template ID is required");
1202                Validate.notNull(theScript, "Script is required");
1203
1204                CdaTemplateJson result = myRestClient
1205                        .put()
1206                        .uri("cda/{moduleId}/template/{templateId}/script", theModuleId, theTemplateId)
1207                        .contentType(MediaType.parseMediaType("application/javascript"))
1208                        .body(theScript)
1209                        .retrieve()
1210                        .body(CdaTemplateJson.class);
1211                return Objects.requireNonNull(result);
1212        }
1213
1214        @Nonnull
1215        public CdaTemplateViewAllResponseJson getCdaTemplates(String theModuleId) {
1216                Validate.notEmpty(theModuleId, "Module ID is required");
1217
1218                CdaTemplateViewAllResponseJson result = myRestClient
1219                        .get()
1220                        .uri("cda/{moduleId}/template", theModuleId)
1221                        .retrieve()
1222                        .body(CdaTemplateViewAllResponseJson.class);
1223                return Objects.requireNonNull(result);
1224        }
1225
1226        @Nonnull
1227        public CdaTemplateResponseJson getCdaTemplate(String theModuleId, String theTemplateId) {
1228                Validate.notEmpty(theModuleId, "Module ID is required");
1229                Validate.notEmpty(theTemplateId, "Template ID is required");
1230
1231                CdaTemplateResponseJson result = myRestClient
1232                        .get()
1233                        .uri("cda/{moduleId}/template/{templateId}", theModuleId, theTemplateId)
1234                        .retrieve()
1235                        .body(CdaTemplateResponseJson.class);
1236                return Objects.requireNonNull(result);
1237        }
1238
1239        public void deleteCdaTemplate(String theModuleId, String theTemplateId) {
1240                Validate.notEmpty(theModuleId, "Module ID is required");
1241                Validate.notEmpty(theTemplateId, "Template ID is required");
1242
1243                myRestClient
1244                        .delete()
1245                        .uri("cda/{moduleId}/template/{templateId}", theModuleId, theTemplateId)
1246                        .retrieve()
1247                        .toBodilessEntity();
1248        }
1249
1250        @Nonnull
1251        public PrivacyNoticeJson getPrivacyNotice() {
1252                PrivacyNoticeJson result = myRestClient
1253                        .get()
1254                        .uri("admin-json/privacy-notice")
1255                        .retrieve()
1256                        .body(PrivacyNoticeJson.class);
1257                return Objects.requireNonNull(result);
1258        }
1259
1260        @Nonnull
1261        public PrivacyNoticeStatusJson getPrivacyNoticeStatus() {
1262                PrivacyNoticeStatusJson result = myRestClient
1263                        .get()
1264                        .uri("admin-json/privacy-notice/status")
1265                        .retrieve()
1266                        .body(PrivacyNoticeStatusJson.class);
1267                return Objects.requireNonNull(result);
1268        }
1269
1270        @Nonnull
1271        public PrivacyNoticeAcceptanceJson acceptPrivacyNotice(String theVersion) {
1272                Validate.notEmpty(theVersion, "Version is required");
1273
1274                PrivacyNoticeAcceptanceJson result = myRestClient
1275                        .post()
1276                        .uri("admin-json/privacy-notice/accept")
1277                        .contentType(MediaType.APPLICATION_JSON)
1278                        .body(new PrivacyNoticeAcceptRequestJson(theVersion))
1279                        .retrieve()
1280                        .body(PrivacyNoticeAcceptanceJson.class);
1281                return Objects.requireNonNull(result);
1282        }
1283
1284        @Nonnull
1285        public OAuth2KeystoresJson getKeystores(int thePageIndex, int thePageSize) {
1286                OAuth2KeystoresJson result = myRestClient
1287                        .get()
1288                        .uri("keystores?pageIndex={pageIndex}&pageSize={pageSize}", thePageIndex, thePageSize)
1289                        .retrieve()
1290                        .body(OAuth2KeystoresJson.class);
1291                return Objects.requireNonNull(result);
1292        }
1293
1294        @Nonnull
1295        public OAuth2KeystoreJson getKeystore(String theKeystoreId) {
1296                Validate.notEmpty(theKeystoreId, "Keystore ID is required");
1297
1298                OAuth2KeystoreJson result = myRestClient
1299                        .get()
1300                        .uri("keystores/{keystoreId}", theKeystoreId)
1301                        .retrieve()
1302                        .body(OAuth2KeystoreJson.class);
1303                return Objects.requireNonNull(result);
1304        }
1305
1306        @Nonnull
1307        public OAuth2KeystoreJson createKeystore(@Nonnull OAuth2KeystoreJson theKeystore) {
1308                Validate.notNull(theKeystore, "Keystore is required");
1309
1310                OAuth2KeystoreJson result = myRestClient
1311                        .post()
1312                        .uri("keystores")
1313                        .contentType(MediaType.APPLICATION_JSON)
1314                        .body(theKeystore)
1315                        .retrieve()
1316                        .body(OAuth2KeystoreJson.class);
1317                return Objects.requireNonNull(result);
1318        }
1319
1320        @Nonnull
1321        public OAuth2KeystoreJson updateKeystore(String theKeystoreId, @Nonnull OAuth2KeystoreJson theKeystore) {
1322                Validate.notEmpty(theKeystoreId, "Keystore ID is required");
1323                Validate.notNull(theKeystore, "Keystore is required");
1324
1325                OAuth2KeystoreJson result = myRestClient
1326                        .put()
1327                        .uri("keystores/{keystoreId}", theKeystoreId)
1328                        .contentType(MediaType.APPLICATION_JSON)
1329                        .body(theKeystore)
1330                        .retrieve()
1331                        .body(OAuth2KeystoreJson.class);
1332                return Objects.requireNonNull(result);
1333        }
1334
1335        public void deleteKeystore(String theKeystoreId) {
1336                Validate.notEmpty(theKeystoreId, "Keystore ID is required");
1337
1338                myRestClient
1339                        .delete()
1340                        .uri("keystores/{keystoreId}", theKeystoreId)
1341                        .retrieve()
1342                        .toBodilessEntity();
1343        }
1344
1345        @Nonnull
1346        public OAuth2RevokeAllWithScopeResponseJson revokeAllTokens(
1347                @Nullable String theNodeId, @Nullable String theModuleId, @Nullable String theScope) {
1348                OAuth2RevokeAllWithScopeResponseJson result = myRestClient
1349                        .delete()
1350                        .uri("openid-connect-sessions/revoke/all-tokens", builder -> {
1351                                builder.queryParamIfPresent("node_id", Optional.ofNullable(theNodeId));
1352                                builder.queryParamIfPresent("module_id", Optional.ofNullable(theModuleId));
1353                                builder.queryParamIfPresent("scope", Optional.ofNullable(theScope));
1354                                return builder.build();
1355                        })
1356                        .retrieve()
1357                        .body(OAuth2RevokeAllWithScopeResponseJson.class);
1358                return Objects.requireNonNull(result);
1359        }
1360
1361        /**
1362         * Get MDM links
1363         */
1364        @Nonnull
1365        public JsonNode getMdmLinks(String theModuleId) {
1366                Validate.notEmpty(theModuleId, "Module ID is required");
1367
1368                JsonNode result = myRestClient
1369                        .get()
1370                        .uri("mdm/{moduleId}/query-links", theModuleId)
1371                        .retrieve()
1372                        .body(JsonNode.class);
1373                return Objects.requireNonNull(result);
1374        }
1375
1376        /**
1377         * Merge MDM golden resources
1378         */
1379        @Nonnull
1380        public JsonNode mergeMdmGoldenResources(String theModuleId, Object theRequest) {
1381                Validate.notEmpty(theModuleId, "Module ID is required");
1382                Validate.notNull(theRequest, "Request is required");
1383
1384                JsonNode result = myRestClient
1385                        .post()
1386                        .uri("mdm/{moduleId}/merge-golden-resources", theModuleId)
1387                        .body(theRequest)
1388                        .retrieve()
1389                        .body(JsonNode.class);
1390                return Objects.requireNonNull(result);
1391        }
1392
1393        /**
1394         * Update MDM link
1395         */
1396        @Nonnull
1397        public JsonNode updateMdmLink(String theModuleId, Object theRequest) {
1398                Validate.notEmpty(theModuleId, "Module ID is required");
1399                Validate.notNull(theRequest, "Request is required");
1400
1401                JsonNode result = myRestClient
1402                        .post()
1403                        .uri("mdm/{moduleId}/update-link", theModuleId)
1404                        .body(theRequest)
1405                        .retrieve()
1406                        .body(JsonNode.class);
1407                return Objects.requireNonNull(result);
1408        }
1409
1410        /**
1411         * Get MDM duplicate golden resources
1412         */
1413        @Nonnull
1414        public JsonNode getMdmDuplicateGoldenResources(String theModuleId) {
1415                Validate.notEmpty(theModuleId, "Module ID is required");
1416
1417                JsonNode result = myRestClient
1418                        .get()
1419                        .uri("mdm/{moduleId}/duplicate-golden-resources", theModuleId)
1420                        .retrieve()
1421                        .body(JsonNode.class);
1422                return Objects.requireNonNull(result);
1423        }
1424
1425        /**
1426         * Submit an MDM operation to perform batch matching on all resources
1427         *
1428         * @param theModuleId   The module ID
1429         * @param theParameters The parameters for the MDM operation
1430         * @return The response as a String
1431         */
1432        @Nonnull
1433        public String submitMdmOperation(String theModuleId, IBaseParameters theParameters) {
1434                Validate.notEmpty(theModuleId, "Module ID is required");
1435
1436                String body = toJsonString(theParameters);
1437
1438                String result = myRestClient
1439                        .post()
1440                        .uri("{moduleId}/mdm-submit", theModuleId)
1441                        .body(body)
1442                        .retrieve()
1443                        .body(String.class);
1444
1445                return result != null ? result : "";
1446        }
1447
1448        public static String toJsonString(IBaseResource theResource) {
1449                FhirVersionEnum version = FhirVersionEnum.determineVersionForType(theResource.getClass());
1450                FhirContext ctx = FhirContext.forCached(version);
1451                return ctx.newJsonParser().encodeResourceToString(theResource);
1452        }
1453
1454        /**
1455         * Exception thrown when a forbidden operation is attempted
1456         */
1457        public static class ForbiddenOperationException extends RuntimeException {
1458                public ForbiddenOperationException(String message, Throwable cause) {
1459                        super(message, cause);
1460                }
1461        }
1462
1463        /**
1464         * Get the node configurations from the admin API.
1465         *
1466         * @return The node configurations
1467         */
1468        @Nonnull
1469        public NodeConfigurations getNodeConfigurations() {
1470                NodeConfigurations result = myRestClient
1471                        .get()
1472                        .uri("/module-config/")
1473                        .retrieve()
1474                        .body(NodeConfigurations.class);
1475                return Objects.requireNonNull(result);
1476        }
1477
1478        /**
1479         * Get the port number for a specific module from the node configurations.
1480         *
1481         * @param theModuleId The ID of the module to get the port for
1482         * @return The port number, or the default FHIR port (8000) if not found
1483         */
1484        public int getPortFromModule(String theModuleId) {
1485                Validate.notEmpty(theModuleId, "Module ID is required");
1486                NodeConfigurations config = getNodeConfigurations();
1487
1488                // Find the first node (assuming there's only one node in the container)
1489                if (config.getNodes().isEmpty()) {
1490                        throw new IllegalStateException("No nodes found in node configuration");
1491                } else {
1492                        NodeConfigurations.NodeConfiguration node = config.getNodes().get(0);
1493
1494                        // Find the module with the given ID
1495                        return node.getModule(theModuleId)
1496                                .map(module -> {
1497                                        // Look for the port property in the module's configuration
1498                                        for (NodeConfigurations.ModuleConfigProperty property : module.getConfigProperties()) {
1499                                                if (property.getKey().equals("port")) {
1500                                                        try {
1501                                                                return Integer.parseInt(property.getValue());
1502                                                        } catch (NumberFormatException e) {
1503                                                                // If the port is not a valid integer, fail.
1504                                                                throw new IllegalArgumentException("Invalid port number: " + property.getValue(), e);
1505                                                        }
1506                                                }
1507                                        }
1508                                        // If no port property is found, return the default FHIR port
1509                                        return null;
1510                                })
1511                                .orElseThrow(() -> new IllegalArgumentException("Module with ID " + theModuleId + " not found in node configuration"));
1512                }
1513
1514        }
1515
1516        /**
1517         * Find the module ID of the FHIR endpoint (FHIR REST, hybrid providers, or FHIR gateway) running
1518         * on the given port.
1519         *
1520         * @param thePort The port to look up
1521         * @return The module ID of the FHIR endpoint module running on the given port
1522         * @throws IllegalArgumentException if no FHIR endpoint module is found running on the given port
1523         * @throws IllegalStateException if no nodes are found in the node configuration
1524         */
1525        @Nonnull
1526        public String getFhirEndpointModuleIdFromPort(int thePort) {
1527                final NodeConfigurations config = getNodeConfigurations();
1528                // Find the first node (assuming there's only one node in the container)
1529                if (config.getNodes().isEmpty()) {
1530                        throw new IllegalStateException("No nodes found in node configuration");
1531                } else {
1532                        final NodeConfigurations.NodeConfiguration node = config.getNodes().get(0);
1533                        // Find the module with the given ID
1534                        return node.getModules().stream()
1535                                .filter(theModuleConfiguration -> theModuleConfiguration.isFhirEndpointModuleType()
1536                                        && thePort == Integer.parseInt(theModuleConfiguration.getConfigProperty("port")))
1537                                .findFirst()
1538                                .map(NodeConfigurations.ModuleConfiguration::getModuleId)
1539                                .orElseThrow(() -> new IllegalArgumentException("Unable to find FHIR Endpoint module running on Port " + thePort));
1540                }
1541        }
1542
1543        /**
1544         * 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
1545         */
1546        public <T> T convertJsonNodeToModel(JsonNode jsonNode, Class<T> modelClass) throws JsonProcessingException {
1547                // This is a hack to work around many of our return types being hidden in cdr-api instead of cdr-api-public.
1548                // From Gary: We can't extract them cleanly yet to the fact that they often are relying on enums in clustermgr
1549                // or enums in api that we do not currently want to make public,
1550                // usually via 3 separate levels of nesting (e.g. node configurations -> module config -> ModuleTypeEnum
1551                // TODO: Fix these, and move them to public, updating the method types to our model classes.
1552
1553                try {
1554                        ObjectMapper objectMapper = new ObjectMapper();
1555                        // Direct conversion from JsonNode to model class without string conversion
1556                        return objectMapper.treeToValue(jsonNode, modelClass);
1557                } catch (JsonProcessingException e) {
1558                        // Log the error with the actual JSON content for debugging
1559                        ourLog.error("Failed to convert JsonNode to " + modelClass.getSimpleName() +
1560                                ". JSON content: " + jsonNode.toString(), e);
1561                        throw e;
1562                }
1563        }
1564
1565        public <T> JsonNode convertModelToJsonNode(T model) throws JsonProcessingException {
1566                try {
1567                        ObjectMapper objectMapper = new ObjectMapper();
1568                        // Direct conversion from model to JsonNode without going through string
1569                        return objectMapper.valueToTree(model);
1570                } catch (Exception e) {
1571                        throw new JsonProcessingException("Error converting model to JsonNode") {} ;
1572                }
1573        }
1574
1575
1576        @Nullable
1577        public TransactionLogEventsJson.TransactionLogEventJson getMostRecentTransactionLogEntry() {
1578                TransactionLogEventsJson events = myRestClient.get().uri("/transaction-log/?pageSize=1").retrieve().body(TransactionLogEventsJson.class);
1579                if (events == null || events.getEvents() == null || events.getEvents().isEmpty()) {
1580                        return null;
1581                } else {
1582                        return events.getEvents().get(0);
1583                }
1584        }
1585
1586        @Nullable
1587        public Long getMostRecentTransactionLogEntryId() {
1588                TransactionLogEventsJson.TransactionLogEventJson entry = getMostRecentTransactionLogEntry();
1589                if (entry != null) {
1590                        return entry.getId();
1591                } else {
1592                        return null;
1593                }
1594        }
1595
1596        public void awaitNewTransactionLogEntry(Long theMostRecentTxLogEntryId) {
1597                await().until(this::getMostRecentTransactionLogEntryId, t -> !Objects.equals(theMostRecentTxLogEntryId, t));
1598        }
1599
1600        /**
1601         * Fetch a transaction log entry by ID and return all details (includes the body)
1602         */
1603        public TransactionLogEventsJson.TransactionLogEventJson getTransactionLogEntryById(Long theId) {
1604                return myRestClient.get().uri("/transaction-log/clustermgr/event/" +theId+ "?includeBody=true").retrieve().body(TransactionLogEventsJson.TransactionLogEventJson.class);
1605        }
1606
1607        /**
1608         * Perform a generic GET request to the specified path and return the response as JsonNode
1609         *
1610         * @param thePath The path to perform the GET request on
1611         * @return The response as JsonNode
1612         */
1613        @Nonnull
1614        public JsonNode get(String thePath) {
1615                Validate.notEmpty(thePath, "Path is required");
1616                JsonNode result = myRestClient
1617                        .get()
1618                        .uri(thePath)
1619                        .retrieve()
1620                        .body(JsonNode.class);
1621                return Objects.requireNonNull(result);
1622        }
1623
1624        /**
1625         * Releases the connection pool this client built for itself. A no-op when the client was built
1626         * over one supplied by a caller, which owns its own pool.
1627         */
1628        @Override
1629        public void close() {
1630                if (myOwnership != null) {
1631                        myOwnership.closeIfOwned();
1632                }
1633        }
1634}