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 com.fasterxml.jackson.databind.JsonNode;
017import com.fasterxml.jackson.databind.node.JsonNodeFactory;
018import jakarta.annotation.Nonnull;
019import jakarta.annotation.Nullable;
020import org.apache.commons.lang3.Validate;
021import org.springframework.http.MediaType;
022import org.springframework.http.client.support.BasicAuthenticationInterceptor;
023import org.springframework.web.client.RestClient;
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                                .defaultRequest(r -> r.accept(MediaType.APPLICATION_JSON));
109                if (theUsername != null && thePassword != null) {
110                        builder.requestInterceptor(new BasicAuthenticationInterceptor(theUsername, thePassword));
111                }
112                return new CdsHooksClient(builder.build(), theOwnership);
113        }
114
115        /**
116         * Lists the services this endpoint offers: {@code GET {base}/cds-services}.
117         *
118         * @return the discovery response, whose {@code services} array describes each service
119         */
120        @Nonnull
121        public JsonNode discovery() {
122                return Objects.requireNonNull(
123                                myRestClient.get().uri("cds-services").retrieve().body(JsonNode.class));
124        }
125
126        /**
127         * Invokes a service: {@code POST {base}/cds-services/{id}}.
128         *
129         * @param theServiceId the service's {@code id} from {@link #discovery()}
130         * @param theRequest the CDS Hooks request, carrying {@code hook}, {@code hookInstance},
131         *    {@code context} and optionally {@code prefetch}
132         * @return the CDS Hooks response, whose {@code cards} array holds the service's advice
133         */
134        @Nonnull
135        public JsonNode invoke(@Nonnull String theServiceId, @Nonnull JsonNode theRequest) {
136                Validate.notEmpty(theServiceId, "Service ID is required");
137                return Objects.requireNonNull(
138                                myRestClient
139                                                .post()
140                                                .uri("cds-services/{id}", theServiceId)
141                                                .contentType(MediaType.APPLICATION_JSON)
142                                                .body(theRequest)
143                                                .retrieve()
144                                                .body(JsonNode.class),
145                                "CDS service " + theServiceId + " returned no body");
146        }
147
148        /**
149         * Reports what became of a service's cards: {@code POST {base}/cds-services/{id}/feedback}.
150         *
151         * @param theServiceId the service's {@code id} from {@link #discovery()}
152         * @param theFeedback the feedback request, whose {@code feedback} array names each card and its
153         *    outcome
154         * @return the response body, which the specification leaves empty; an empty object if there was
155         *    none
156         */
157        @Nonnull
158        public JsonNode feedback(@Nonnull String theServiceId, @Nonnull JsonNode theFeedback) {
159                Validate.notEmpty(theServiceId, "Service ID is required");
160                JsonNode result = myRestClient
161                                .post()
162                                .uri("cds-services/{id}/feedback", theServiceId)
163                                .contentType(MediaType.APPLICATION_JSON)
164                                .body(theFeedback)
165                                .retrieve()
166                                .body(JsonNode.class);
167                return result != null ? result : JsonNodeFactory.instance.objectNode();
168        }
169
170        /**
171         * Releases the connection pool this client built for itself. A no-op when the client was built
172         * over one supplied by a caller, which owns its own pool.
173         */
174        @Override
175        public void close() {
176                myOwnership.closeIfOwned();
177        }
178}