001/*-
002 * #%L
003 * Smile CDR - CDR
004 * %%
005 * Copyright (C) 2016 - 2026 Smile CDR, Inc.
006 * %%
007 * All rights reserved.
008 * #L%
009 */
010package ca.cdr.test.app.clients;
011
012import ca.cdr.test.app.clients.common.HttpClientOwnership;
013import ca.cdr.test.app.clients.common.RequestFactoryUtil;
014import ca.cdr.test.app.clients.common.SmileTestHttpClient;
015import ca.cdr.test.util.UrlPathUtil;
016import jakarta.annotation.Nonnull;
017import jakarta.annotation.Nullable;
018import org.apache.commons.lang3.Validate;
019import org.springframework.http.MediaType;
020import org.springframework.http.client.support.BasicAuthenticationInterceptor;
021import org.springframework.web.client.RestClient;
022import tools.jackson.databind.JsonNode;
023import tools.jackson.databind.node.JsonNodeFactory;
024
025import java.util.Objects;
026
027/**
028 * A client for a Smile CDR CDS Hooks endpoint ({@code ENDPOINT_CDS_HOOKS}), covering the three
029 * endpoints of the CDS Hooks specification: discovery, service invocation and feedback.
030 *
031 * @see <a href="https://cds-hooks.hl7.org/">CDS Hooks</a>
032 */
033// Created by Claude Opus 5.5
034public class CdsHooksClient implements AutoCloseable {
035
036        private final RestClient myRestClient;
037
038        /**
039         * The client this one issues on, and whether closing this client should release it.
040         */
041        private final HttpClientOwnership myOwnership;
042
043        private CdsHooksClient(RestClient theRestClient, HttpClientOwnership theOwnership) {
044                myRestClient = theRestClient;
045                myOwnership = theOwnership;
046        }
047
048        /**
049         * Opens a client authenticating as the given user, over a connection pool of its own.
050         * <p>
051         * The caller owns the pool: open this in a try-with-resources block, or close it from an
052         * {@code @AfterAll} when it is a field. Use
053         * {@link #issuingOn(SmileTestHttpClient, String, String, String)} instead where a client to
054         * issue on already exists.
055         *
056         * @param theBaseUrl the base URL of the CDS Hooks endpoint, including port and context path
057         * @param theUsername the user to authenticate as
058         * @param thePassword that user's password
059         * @return a new client
060         */
061        public static @Nonnull CdsHooksClient open(
062                        @Nonnull String theBaseUrl, @Nonnull String theUsername, @Nonnull String thePassword) {
063                return clientOn(HttpClientOwnership.resolve(null), theBaseUrl, theUsername, thePassword);
064        }
065
066        /**
067         * Opens a client sending no credentials, over a connection pool of its own. CDS Hooks services
068         * are often exposed without authentication.
069         * <p>
070         * The caller owns the pool — see {@link #open(String, String, String)}.
071         *
072         * @param theBaseUrl the base URL of the CDS Hooks endpoint, including port and context path
073         * @return a new client
074         */
075        public static @Nonnull CdsHooksClient openAnonymous(@Nonnull String theBaseUrl) {
076                return clientOn(HttpClientOwnership.resolve(null), theBaseUrl, null, null);
077        }
078
079        /**
080         * Builds a client that issues its requests on {@code theHttpClient}, sharing that client's
081         * connection pool and cookie store with everything else built over it.
082         * <p>
083         * The pool stays the caller's: {@link #close()} leaves it open, so there is nothing here for the
084         * caller to release. Use {@link #open(String, String, String)} to build a pool of your own.
085         *
086         * @param theHttpClient the client to issue on, which the caller keeps ownership of
087         * @param theBaseUrl the base URL of the CDS Hooks endpoint, including port and context path
088         * @param theUsername the user to authenticate as, or {@literal null} to send no credentials
089         * @param thePassword that user's password
090         * @return a new client
091         */
092        public static @Nonnull CdsHooksClient issuingOn(
093                        @Nonnull SmileTestHttpClient theHttpClient,
094                        @Nonnull String theBaseUrl,
095                        @Nullable String theUsername,
096                        @Nullable String thePassword) {
097                return clientOn(HttpClientOwnership.resolve(theHttpClient), theBaseUrl, theUsername, thePassword);
098        }
099
100        private static @Nonnull CdsHooksClient clientOn(
101                        @Nonnull HttpClientOwnership theOwnership,
102                        @Nonnull String theBaseUrl,
103                        @Nullable String theUsername,
104                        @Nullable String thePassword) {
105                RestClient.Builder builder = RestClient.builder()
106                                .baseUrl(UrlPathUtil.withTrailingSlash(theBaseUrl))
107                                .requestFactory(RequestFactoryUtil.wrap(theOwnership.client()))
108                                .configureMessageConverters(ClientMessageConverters::configure)
109                                .defaultRequest(r -> r.accept(MediaType.APPLICATION_JSON));
110                if (theUsername != null && thePassword != null) {
111                        builder.requestInterceptor(new BasicAuthenticationInterceptor(theUsername, thePassword));
112                }
113                return new CdsHooksClient(builder.build(), theOwnership);
114        }
115
116        /**
117         * Lists the services this endpoint offers: {@code GET {base}/cds-services}.
118         *
119         * @return the discovery response, whose {@code services} array describes each service
120         */
121        @Nonnull
122        public JsonNode discovery() {
123                return Objects.requireNonNull(
124                                myRestClient.get().uri("cds-services").retrieve().body(JsonNode.class));
125        }
126
127        /**
128         * Invokes a service: {@code POST {base}/cds-services/{id}}.
129         *
130         * @param theServiceId the service's {@code id} from {@link #discovery()}
131         * @param theRequest the CDS Hooks request, carrying {@code hook}, {@code hookInstance},
132         *    {@code context} and optionally {@code prefetch}
133         * @return the CDS Hooks response, whose {@code cards} array holds the service's advice
134         */
135        @Nonnull
136        public JsonNode invoke(@Nonnull String theServiceId, @Nonnull JsonNode theRequest) {
137                Validate.notEmpty(theServiceId, "Service ID is required");
138                return Objects.requireNonNull(
139                                myRestClient
140                                                .post()
141                                                .uri("cds-services/{id}", theServiceId)
142                                                .contentType(MediaType.APPLICATION_JSON)
143                                                .body(theRequest)
144                                                .retrieve()
145                                                .body(JsonNode.class),
146                                "CDS service " + theServiceId + " returned no body");
147        }
148
149        /**
150         * Reports what became of a service's cards: {@code POST {base}/cds-services/{id}/feedback}.
151         *
152         * @param theServiceId the service's {@code id} from {@link #discovery()}
153         * @param theFeedback the feedback request, whose {@code feedback} array names each card and its
154         *    outcome
155         * @return the response body, which the specification leaves empty; an empty object if there was
156         *    none
157         */
158        @Nonnull
159        public JsonNode feedback(@Nonnull String theServiceId, @Nonnull JsonNode theFeedback) {
160                Validate.notEmpty(theServiceId, "Service ID is required");
161                JsonNode result = myRestClient
162                                .post()
163                                .uri("cds-services/{id}/feedback", theServiceId)
164                                .contentType(MediaType.APPLICATION_JSON)
165                                .body(theFeedback)
166                                .retrieve()
167                                .body(JsonNode.class);
168                return result != null ? result : JsonNodeFactory.instance.objectNode();
169        }
170
171        /**
172         * Releases the connection pool this client built for itself. A no-op when the client was built
173         * over one supplied by a caller, which owns its own pool.
174         */
175        @Override
176        public void close() {
177                myOwnership.closeIfOwned();
178        }
179}