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}