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}