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