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}