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.uhn.fhir.jpa.packages.NpmPackageAssetInfoListJson; 016import ca.uhn.fhir.jpa.packages.NpmPackageMetadataJson; 017import ca.uhn.fhir.jpa.packages.NpmPackageSearchResultJson; 018import ca.uhn.fhir.jpa.packages.PackageDeleteOutcomeJson; 019import ca.uhn.fhir.jpa.packages.PackageInstallOutcomeJson; 020import ca.uhn.fhir.jpa.packages.PackageInstallationSpec; 021import ca.uhn.fhir.jpa.packages.PackageInstallationStatusJson; 022import jakarta.annotation.Nonnull; 023import jakarta.annotation.Nullable; 024import org.apache.commons.lang3.Validate; 025import org.slf4j.Logger; 026import org.slf4j.LoggerFactory; 027import org.springframework.http.HttpHeaders; 028import org.springframework.http.HttpStatus; 029import org.springframework.http.MediaType; 030import org.springframework.http.ResponseEntity; 031import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; 032import org.springframework.http.client.support.BasicAuthenticationInterceptor; 033import org.springframework.util.MultiValueMap; 034import org.springframework.web.client.RestClient; 035import org.springframework.web.util.UriComponentsBuilder; 036 037import java.util.Objects; 038 039/** 040 * NpmPackageClient is a client for the NPM Package Registry API of the CDR. 041 * It currently supports anonymous and http-basic authentication. 042 * 043 * This client provides access to package installation operations through 044 * Smile CDR's package registry endpoint, typically running on port 8002. 045 */ 046public class NpmPackageClient implements AutoCloseable { 047 private static final Logger ourLog = LoggerFactory.getLogger(NpmPackageClient.class); 048 049 private final RestClient myRestClient; 050 051 /** 052 * The client this one issues on, and whether closing this client should release it. 053 * {@literal null} for a client built from a {@link RestClient} directly, which brought no 054 * connection pool of its own. 055 */ 056 private final HttpClientOwnership myOwnership; 057 058 NpmPackageClient(RestClient theRestClient) { 059 this(theRestClient, null); 060 } 061 062 private NpmPackageClient(RestClient theRestClient, @Nullable HttpClientOwnership theOwnership) { 063 myRestClient = theRestClient; 064 myOwnership = theOwnership; 065 } 066 067 /** 068 * Build an NpmPackageClient with authentication 069 * @param theBaseUrl The base URL of the package registry 070 * @param theUsername Username for authentication 071 * @param thePassword Password for authentication 072 * @return Configured NpmPackageClient 073 */ 074 /** 075 * Opens a client authenticating as the given user, over a connection pool of its own. 076 * <p> 077 * The caller owns the pool: open this in a try-with-resources block, or close it from an 078 * {@code @AfterAll} when it is a field. Use 079 * {@link #issuingOn(SmileTestHttpClient, String, String, String)} instead where a client to 080 * issue on already exists. 081 * 082 * @param theBaseUrl the base URL of the package registry 083 * @param theUsername the user to authenticate as 084 * @param thePassword that user's password 085 */ 086 public static @Nonnull NpmPackageClient open( 087 @Nonnull String theBaseUrl, @Nonnull String theUsername, @Nonnull String thePassword) { 088 return authenticatedClient(HttpClientOwnership.resolve(null), theBaseUrl, theUsername, thePassword); 089 } 090 091 /** 092 * Opens a client sending no credentials, over a connection pool of its own. 093 * <p> 094 * The caller owns the pool ? see {@link #open(String, String, String)}. 095 * 096 * @param theBaseUrl the base URL of the package registry 097 */ 098 public static @Nonnull NpmPackageClient openAnonymous(@Nonnull String theBaseUrl) { 099 HttpClientOwnership ownership = HttpClientOwnership.resolve(null); 100 101 return new NpmPackageClient( 102 builderForUrlWithJsonDefault(theBaseUrl, ownership.client()).build(), ownership); 103 } 104 105 /** 106 * Builds a client that issues its requests on {@code theHttpClient}, sharing that client's 107 * connection pool and cookie store with everything else built over it. 108 * <p> 109 * The pool stays the caller's: {@link #close()} leaves it open, so there is nothing here for the 110 * caller to release. Use {@link #open(String, String, String)} to build a pool of your own. 111 * 112 * @param theHttpClient the client to issue on, which the caller keeps ownership of 113 * @param theBaseUrl the base URL of the package registry 114 * @param theUsername the user to authenticate as 115 * @param thePassword that user's password 116 * @see ca.cdr.test.app.clients.common.RequestFactoryUtil#wrap(SmileTestHttpClient) 117 */ 118 public static @Nonnull NpmPackageClient issuingOn( 119 @Nonnull SmileTestHttpClient theHttpClient, 120 @Nonnull String theBaseUrl, 121 @Nonnull String theUsername, 122 @Nonnull String thePassword) { 123 return authenticatedClient( 124 HttpClientOwnership.resolve(theHttpClient), theBaseUrl, theUsername, thePassword); 125 } 126 127 private static @Nonnull NpmPackageClient authenticatedClient( 128 @Nonnull HttpClientOwnership theOwnership, 129 @Nonnull String theBaseUrl, 130 @Nonnull String theUsername, 131 @Nonnull String thePassword) { 132 RestClient restClient = builderForUrlWithJsonDefault(theBaseUrl, theOwnership.client()) 133 .requestInterceptor(new BasicAuthenticationInterceptor(theUsername, thePassword)) 134 .build(); 135 136 return new NpmPackageClient(restClient, theOwnership); 137 } 138 139 /** 140 * @deprecated Use {@link #open(String, String, String)}, whose name says that the client owns the 141 * connection pool it returns and that the caller has to close it. 142 */ 143 @Deprecated(since = "2026.11.R01", forRemoval = true) 144 public static @Nonnull NpmPackageClient build( 145 @Nonnull String theBaseUrl, @Nonnull String theUsername, @Nonnull String thePassword) { 146 return open(theBaseUrl, theUsername, thePassword); 147 } 148 149 /** 150 * @deprecated Use {@link #openAnonymous(String)}, whose name says that the client owns the 151 * connection pool it returns and that the caller has to close it. 152 */ 153 @Deprecated(since = "2026.11.R01", forRemoval = true) 154 public static @Nonnull NpmPackageClient buildAnonymous(@Nonnull String theBaseUrl) { 155 return openAnonymous(theBaseUrl); 156 } 157 158 /** 159 * Install a package using the provided specification 160 * @param theSpec The package installation specification 161 * @return The installation outcome 162 */ 163 @Nonnull 164 public PackageInstallOutcomeJson installPackageBySpec(PackageInstallationSpec theSpec) { 165 Validate.notNull(theSpec, "Package specification is required"); 166 Validate.notEmpty(theSpec.getName(), "Package name is required"); 167 Validate.notEmpty(theSpec.getVersion(), "Package version is required"); 168 169 ourLog.info("Installing package {}#{}", theSpec.getName(), theSpec.getVersion()); 170 171 PackageInstallOutcomeJson result = myRestClient 172 .put() 173 .uri("/write/install/by-spec") 174 .body(theSpec) 175 .contentType(MediaType.APPLICATION_JSON) 176 .retrieve() 177 .body(PackageInstallOutcomeJson.class); 178 return Objects.requireNonNull(result); 179 } 180 181 /** 182 * Install a package using the provided specification as a JSON string 183 * @param theSpecJson The package installation specification as JSON string 184 * @return The installation outcome 185 */ 186 @Nonnull 187 public PackageInstallOutcomeJson installPackageBySpec(String theSpecJson) { 188 Validate.notEmpty(theSpecJson, "Package specification JSON is required"); 189 190 ourLog.info("Installing package from JSON specification: {}", theSpecJson); 191 192 PackageInstallOutcomeJson result = myRestClient 193 .put() 194 .uri("/write/install/by-spec") 195 .contentType(MediaType.APPLICATION_JSON) 196 .body(theSpecJson) 197 .retrieve() 198 .body(PackageInstallOutcomeJson.class); 199 return Objects.requireNonNull(result); 200 } 201 202 @Nonnull 203 public String installPackageBySpecAsync(@Nonnull PackageInstallationSpec theSpec) { 204 Validate.notNull(theSpec, "Package specification is required"); 205 206 ResponseEntity<Void> response = myRestClient 207 .put() 208 .uri("/write/install/by-spec") 209 .header("Prefer", "respond-async") 210 .contentType(MediaType.APPLICATION_JSON) 211 .body(theSpec) 212 .retrieve() 213 .toBodilessEntity(); 214 Validate.isTrue( 215 response.getStatusCode().isSameCodeAs(HttpStatus.ACCEPTED), 216 "Expected 202 Accepted for an async install but got %s", 217 response.getStatusCode()); 218 String pollLocation = Validate.notEmpty( 219 response.getHeaders().getFirst(HttpHeaders.CONTENT_LOCATION), 220 "Async install response had no Content-Location"); 221 String jobId = UriComponentsBuilder.fromUriString(pollLocation).build().getQueryParams().getFirst("_jobId"); 222 return Validate.notEmpty(jobId, "Content-Location %s had no _jobId", pollLocation); 223 } 224 225 @Nonnull 226 public PackageInstallationStatusJson getInstallationStatus(String theJobId) { 227 Validate.notEmpty(theJobId, "Job ID is required"); 228 229 PackageInstallationStatusJson result = myRestClient 230 .get() 231 .uri("/write/install/status?_jobId={jobId}", theJobId) 232 .retrieve() 233 .body(PackageInstallationStatusJson.class); 234 return Objects.requireNonNull(result); 235 } 236 237 @Nonnull 238 public PackageInstallOutcomeJson installPackageByParam( 239 String theName, 240 String theVersion, 241 boolean theFetchDependencies, 242 @Nonnull PackageInstallationSpec.InstallModeEnum theInstallMode, 243 @Nonnull PackageInstallationSpec.VersionPolicyEnum theVersionPolicy) { 244 Validate.notEmpty(theName, "Package name is required"); 245 Validate.notEmpty(theVersion, "Package version is required"); 246 247 PackageInstallOutcomeJson result = myRestClient 248 .put() 249 .uri(builder -> builder.path("/write/install/by-param") 250 .queryParam("name", theName) 251 .queryParam("version", theVersion) 252 .queryParam("fetchDependencies", theFetchDependencies) 253 .queryParam("installMode", theInstallMode) 254 .queryParam("versionPolicy", theVersionPolicy) 255 .build()) 256 .retrieve() 257 .body(PackageInstallOutcomeJson.class); 258 return Objects.requireNonNull(result); 259 } 260 261 @Nonnull 262 public PackageDeleteOutcomeJson deletePackage(String theName, String theVersion) { 263 Validate.notEmpty(theName, "Package name is required"); 264 Validate.notEmpty(theVersion, "Package version is required"); 265 266 PackageDeleteOutcomeJson result = myRestClient 267 .delete() 268 .uri("/write/{name}/{version}", theName, theVersion) 269 .retrieve() 270 .body(PackageDeleteOutcomeJson.class); 271 return Objects.requireNonNull(result); 272 } 273 274 @Nonnull 275 public NpmPackageSearchResultJson searchPackages(@Nonnull MultiValueMap<String, String> theQueryParams) { 276 NpmPackageSearchResultJson result = myRestClient 277 .get() 278 .uri(builder -> builder.path("/npm/-/v1/search").queryParams(theQueryParams).build()) 279 .retrieve() 280 .body(NpmPackageSearchResultJson.class); 281 return Objects.requireNonNull(result); 282 } 283 284 @Nonnull 285 public NpmPackageMetadataJson getPackageMetadata(String theName) { 286 Validate.notEmpty(theName, "Package name is required"); 287 288 NpmPackageMetadataJson result = myRestClient 289 .get() 290 .uri("/npm/{name}", theName) 291 .retrieve() 292 .body(NpmPackageMetadataJson.class); 293 return Objects.requireNonNull(result); 294 } 295 296 @Nonnull 297 public NpmPackageAssetInfoListJson findPackageAssetInfoByUrl(String theFhirVersion, String theCanonicalUrl) { 298 Validate.notEmpty(theFhirVersion, "FHIR version is required"); 299 Validate.notEmpty(theCanonicalUrl, "Canonical URL is required"); 300 301 NpmPackageAssetInfoListJson result = myRestClient 302 .get() 303 .uri(builder -> builder.path("/npm/-/v1/findPackageAssetInfoByUrl") 304 .queryParam("_fhirVersion", theFhirVersion) 305 .queryParam("_canonicalUrl", theCanonicalUrl) 306 .build()) 307 .retrieve() 308 .body(NpmPackageAssetInfoListJson.class); 309 return Objects.requireNonNull(result); 310 } 311 312 /** 313 * Create a RestClient builder configured for JSON communication 314 * @param theBaseUrl The base URL 315 * @return Configured RestClient builder 316 */ 317 private static @Nonnull RestClient.Builder builderForUrlWithJsonDefault( 318 String theBaseUrl, @Nonnull SmileTestHttpClient theHttpClient) { 319 HttpComponentsClientHttpRequestFactory requestFactory = RequestFactoryUtil.wrap(theHttpClient); 320 321 return RestClient.builder() 322 .baseUrl(theBaseUrl) 323 .requestFactory(requestFactory) 324 // This sets the default content type to JSON, but allows the actual request to override it. 325 .defaultRequest(r -> r.accept(MediaType.APPLICATION_JSON)); 326 } 327 328 /** 329 * Releases the connection pool this client built for itself. A no-op when the client was built 330 * over one supplied by a caller, which owns its own pool. 331 */ 332 @Override 333 public void close() { 334 if (myOwnership != null) { 335 myOwnership.closeIfOwned(); 336 } 337 } 338}