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.api.fhir.client.IExtendedGenericClient;
013import ca.cdr.api.model.json.oauth.SmartConfigurationJson;
014import ca.uhn.fhir.context.FhirContext;
015import ca.uhn.fhir.interceptor.api.IInterceptorService;
016import ca.uhn.fhir.jpa.bulk.export.model.BulkExportResponseJson;
017import ca.uhn.fhir.jpa.model.util.JpaConstants;
018import ca.uhn.fhir.model.primitive.IdDt;
019import ca.uhn.fhir.model.primitive.UriDt;
020import ca.uhn.fhir.rest.api.Constants;
021import ca.uhn.fhir.rest.api.EncodingEnum;
022import ca.uhn.fhir.rest.api.MethodOutcome;
023import ca.uhn.fhir.rest.api.RequestFormatParamStyleEnum;
024import ca.uhn.fhir.rest.api.SummaryEnum;
025import ca.uhn.fhir.rest.api.server.bulk.BulkExportJobParameters;
026import ca.uhn.fhir.rest.client.api.IGenericClient;
027import ca.uhn.fhir.rest.client.api.IHttpClient;
028import ca.uhn.fhir.rest.client.exceptions.FhirClientConnectionException;
029import ca.uhn.fhir.rest.gclient.ICreate;
030import ca.uhn.fhir.rest.gclient.IDelete;
031import ca.uhn.fhir.rest.gclient.IFetchConformanceUntyped;
032import ca.uhn.fhir.rest.gclient.IGetPage;
033import ca.uhn.fhir.rest.gclient.IHistory;
034import ca.uhn.fhir.rest.gclient.IMeta;
035import ca.uhn.fhir.rest.gclient.IOperation;
036import ca.uhn.fhir.rest.gclient.IPatch;
037import ca.uhn.fhir.rest.gclient.IRawHttp;
038import ca.uhn.fhir.rest.gclient.IRead;
039import ca.uhn.fhir.rest.gclient.ITransaction;
040import ca.uhn.fhir.rest.gclient.IUntypedQuery;
041import ca.uhn.fhir.rest.gclient.IUpdate;
042import ca.uhn.fhir.rest.gclient.IValidate;
043import ca.uhn.fhir.rest.server.provider.ProviderConstants;
044import ca.uhn.fhir.test.utilities.HttpTestRequest;
045import ca.uhn.fhir.test.utilities.HttpTestResponse;
046import ca.uhn.fhir.util.ParametersUtil;
047import com.fasterxml.jackson.core.JsonProcessingException;
048import com.fasterxml.jackson.databind.ObjectMapper;
049import jakarta.annotation.Nonnull;
050import jakarta.annotation.Nullable;
051import org.hl7.fhir.instance.model.api.IBaseBundle;
052import org.hl7.fhir.instance.model.api.IBaseParameters;
053import org.hl7.fhir.instance.model.api.IBaseResource;
054import org.hl7.fhir.instance.model.api.IPrimitiveType;
055import tools.jackson.databind.json.JsonMapper;
056
057import java.time.Duration;
058import java.time.Instant;
059import java.util.Date;
060import java.util.Objects;
061import java.util.function.Function;
062
063public class CdrFhirClient implements IExtendedGenericClient {
064
065        private final IGenericClient myDelegate;
066        private final Function<String, HttpTestRequest> myPathRequests;
067        private final Function<String, HttpTestRequest> myServerUrlRequests;
068
069        public CdrFhirClient(
070                        @Nonnull IGenericClient theDelegate,
071                        @Nonnull Function<String, HttpTestRequest> thePathRequests,
072                        @Nonnull Function<String, HttpTestRequest> theServerUrlRequests) {
073                myDelegate = Objects.requireNonNull(theDelegate);
074                myPathRequests = Objects.requireNonNull(thePathRequests);
075                myServerUrlRequests = Objects.requireNonNull(theServerUrlRequests);
076        }
077
078        @Override
079        @Nonnull
080        public SmartConfigurationJson getSmartConfiguration() {
081                HttpTestResponse response =
082                                myPathRequests.apply("/.well-known/smart-configuration").get().assertStatus(200);
083                try {
084                        return new ObjectMapper().readValue(response.getBody(), SmartConfigurationJson.class);
085                } catch (JsonProcessingException e) {
086                        throw new IllegalStateException("Unparseable smart-configuration: " + response.getBody(), e);
087                }
088        }
089
090        @Override
091        @Nonnull
092        public String startBulkExport(@Nonnull BulkExportJobParameters theParameters) {
093                FhirContext fhirContext = getFhirContext();
094                IBaseParameters body = ParametersUtil.newInstance(fhirContext);
095                String outputFormat = theParameters.getOutputFormat();
096                ParametersUtil.addParameterToParametersString(
097                                fhirContext,
098                                body,
099                                JpaConstants.PARAM_EXPORT_OUTPUT_FORMAT,
100                                outputFormat == null ? Constants.CT_FHIR_NDJSON : outputFormat);
101                if (!theParameters.getResourceTypes().isEmpty()) {
102                        ParametersUtil.addParameterToParametersString(
103                                        fhirContext,
104                                        body,
105                                        JpaConstants.PARAM_EXPORT_TYPE,
106                                        String.join(",", theParameters.getResourceTypes()));
107                }
108                addInstant(fhirContext, body, JpaConstants.PARAM_EXPORT_SINCE, theParameters.getSince());
109                addInstant(fhirContext, body, JpaConstants.PARAM_EXPORT_UNTIL, theParameters.getUntil());
110                theParameters.getFilters().forEach(filter -> ParametersUtil.addParameterToParametersString(
111                                fhirContext, body, JpaConstants.PARAM_EXPORT_TYPE_FILTER, filter));
112                theParameters.getPostFetchFilterUrls().forEach(url -> ParametersUtil.addParameterToParametersString(
113                                fhirContext, body, JpaConstants.PARAM_EXPORT_TYPE_POST_FETCH_FILTER_URL, url));
114                theParameters.getPatientIds().forEach(patientId -> ParametersUtil.addParameterToParametersReference(
115                                fhirContext, body, JpaConstants.PARAM_EXPORT_PATIENT, patientId));
116                theParameters.getExpandMdm().ifPresent(expandMdm -> ParametersUtil.addParameterToParametersBoolean(
117                                fhirContext, body, JpaConstants.PARAM_EXPORT_MDM, expandMdm));
118                if (theParameters.getExportIdentifier() != null) {
119                        ParametersUtil.addParameterToParametersString(
120                                        fhirContext, body, JpaConstants.PARAM_EXPORT_IDENTIFIER, theParameters.getExportIdentifier());
121                }
122                if (theParameters.isIncludeHistory()) {
123                        ParametersUtil.addParameterToParametersBoolean(
124                                        fhirContext, body, JpaConstants.PARAM_EXPORT_INCLUDE_HISTORY, true);
125                }
126
127                HttpTestResponse response = myPathRequests
128                                .apply(bulkExportPath(theParameters))
129                                .withHeader("Prefer", "respond-async")
130                                .withHeader("Cache-Control", "no-cache")
131                                .post(body)
132                                .assertStatus(202);
133                String pollUrl = response.getHeader("Content-Location");
134                if (pollUrl == null || pollUrl.isBlank()) {
135                        throw new AssertionError("202 Accepted carried no Content-Location");
136                }
137                return pollUrl;
138        }
139
140        @Override
141        @Nonnull
142        public BulkExportResponseJson awaitBulkExport(@Nonnull String thePollUrl) {
143                return awaitBulkExport(thePollUrl, Duration.ofMinutes(5), Duration.ofSeconds(1));
144        }
145
146        @Override
147        @Nonnull
148        public BulkExportResponseJson awaitBulkExport(
149                        @Nonnull String thePollUrl, @Nonnull Duration theTimeout, @Nonnull Duration thePollInterval) {
150                Instant deadline = Instant.now().plus(theTimeout);
151                while (true) {
152                        HttpTestResponse response = myServerUrlRequests.apply(thePollUrl).get();
153                        if (response.getStatusCode() == 200) {
154                                return JsonMapper.builder().build().readValue(response.getBody(), BulkExportResponseJson.class);
155                        }
156                        if (response.getStatusCode() != 202) {
157                                throw new AssertionError("Bulk export at " + thePollUrl + " failed with HTTP "
158                                                + response.getStatusCode() + ": " + response.getBody());
159                        }
160                        if (Instant.now().isAfter(deadline)) {
161                                throw new AssertionError("Bulk export at " + thePollUrl + " still in progress after " + theTimeout
162                                                + " (X-Progress: " + response.getHeader("X-Progress") + ")");
163                        }
164                        try {
165                                Thread.sleep(thePollInterval.toMillis());
166                        } catch (InterruptedException e) {
167                                Thread.currentThread().interrupt();
168                                throw new IllegalStateException("Interrupted awaiting bulk export at " + thePollUrl, e);
169                        }
170                }
171        }
172
173        private static String bulkExportPath(BulkExportJobParameters theParameters) {
174                BulkExportJobParameters.ExportStyle style = theParameters.getExportStyle();
175                if (style == null || style == BulkExportJobParameters.ExportStyle.SYSTEM) {
176                        return "/" + ProviderConstants.OPERATION_EXPORT;
177                }
178                if (style == BulkExportJobParameters.ExportStyle.PATIENT) {
179                        return "/Patient/" + ProviderConstants.OPERATION_EXPORT;
180                }
181                String groupId = theParameters.getGroupId();
182                if (groupId == null || groupId.isBlank()) {
183                        throw new IllegalArgumentException("A GROUP export needs a group ID");
184                }
185                return "/Group/" + groupId.replaceFirst("^Group/", "") + "/" + ProviderConstants.OPERATION_EXPORT;
186        }
187
188        @SuppressWarnings("unchecked")
189        private static void addInstant(
190                        FhirContext theFhirContext, IBaseParameters theBody, String theName, @Nullable Date theValue) {
191                if (theValue == null) {
192                        return;
193                }
194                IPrimitiveType<Date> instant = (IPrimitiveType<Date>)
195                                Objects.requireNonNull(theFhirContext.getElementDefinition("instant")).newInstance();
196                instant.setValue(theValue);
197                ParametersUtil.addParameterToParameters(theFhirContext, theBody, theName, instant);
198        }
199
200        @Override
201        public IFetchConformanceUntyped capabilities() {
202                return myDelegate.capabilities();
203        }
204
205        @Override
206        public ICreate create() {
207                return myDelegate.create();
208        }
209
210        @Override
211        public IDelete delete() {
212                return myDelegate.delete();
213        }
214
215        @Override
216        @Deprecated(since = "3.0.0", forRemoval = true)
217        public IFetchConformanceUntyped fetchConformance() {
218                return myDelegate.fetchConformance();
219        }
220
221        @Override
222        public void forceConformanceCheck() throws FhirClientConnectionException {
223                myDelegate.forceConformanceCheck();
224        }
225
226        @Override
227        public IHistory history() {
228                return myDelegate.history();
229        }
230
231        @Override
232        public IGetPage loadPage() {
233                return myDelegate.loadPage();
234        }
235
236        @Override
237        public IMeta meta() {
238                return myDelegate.meta();
239        }
240
241        @Override
242        public IOperation operation() {
243                return myDelegate.operation();
244        }
245
246        @Override
247        public IPatch patch() {
248                return myDelegate.patch();
249        }
250
251        @Override
252        public IRead read() {
253                return myDelegate.read();
254        }
255
256        @Override
257        public IRawHttp rawHttpRequest() {
258                return myDelegate.rawHttpRequest();
259        }
260
261        @Override
262        @Deprecated
263        public <T extends IBaseResource> T read(Class<T> theType, String theUrl) {
264                return myDelegate.read(theType, theUrl);
265        }
266
267        @Override
268        @Deprecated
269        public <T extends IBaseResource> T read(Class<T> theType, UriDt theUrl) {
270                return myDelegate.read(theType, theUrl);
271        }
272
273        @Override
274        @Deprecated
275        public IBaseResource read(UriDt theUrl) {
276                return myDelegate.read(theUrl);
277        }
278
279        @Override
280        public void registerInterceptor(Object theInterceptor) {
281                myDelegate.registerInterceptor(theInterceptor);
282        }
283
284        @Override
285        public <T extends IBaseBundle> IUntypedQuery<T> search() {
286                return myDelegate.search();
287        }
288
289        @Override
290        @Deprecated
291        public void setLogRequestAndResponse(boolean theLogRequestAndResponse) {
292                myDelegate.setLogRequestAndResponse(theLogRequestAndResponse);
293        }
294
295        @Override
296        public ITransaction transaction() {
297                return myDelegate.transaction();
298        }
299
300        @Override
301        public void unregisterInterceptor(Object theInterceptor) {
302                myDelegate.unregisterInterceptor(theInterceptor);
303        }
304
305        @Override
306        public IUpdate update() {
307                return myDelegate.update();
308        }
309
310        @Override
311        @Deprecated
312        public MethodOutcome update(IdDt theId, IBaseResource theResource) {
313                return myDelegate.update(theId, theResource);
314        }
315
316        @Override
317        @Deprecated
318        public MethodOutcome update(String theId, IBaseResource theResource) {
319                return myDelegate.update(theId, theResource);
320        }
321
322        @Override
323        public IValidate validate() {
324                return myDelegate.validate();
325        }
326
327        @Override
328        @Deprecated
329        public MethodOutcome validate(IBaseResource theResource) {
330                return myDelegate.validate(theResource);
331        }
332
333        @Override
334        @Deprecated
335        public <T extends IBaseResource> T vread(Class<T> theType, IdDt theId) {
336                return myDelegate.vread(theType, theId);
337        }
338
339        @Override
340        @Deprecated
341        public <T extends IBaseResource> T vread(Class<T> theType, String theId, String theVersionId) {
342                return myDelegate.vread(theType, theId, theVersionId);
343        }
344
345        @Override
346        public IInterceptorService getInterceptorService() {
347                return myDelegate.getInterceptorService();
348        }
349
350        @Override
351        public void setInterceptorService(@Nonnull IInterceptorService theInterceptorService) {
352                myDelegate.setInterceptorService(theInterceptorService);
353        }
354
355        @Override
356        public <T extends IBaseResource> T fetchResourceFromUrl(Class<T> theResourceType, String theUrl) {
357                return myDelegate.fetchResourceFromUrl(theResourceType, theUrl);
358        }
359
360        @Override
361        public EncodingEnum getEncoding() {
362                return myDelegate.getEncoding();
363        }
364
365        @Override
366        public void setEncoding(EncodingEnum theEncoding) {
367                myDelegate.setEncoding(theEncoding);
368        }
369
370        @Override
371        public FhirContext getFhirContext() {
372                return myDelegate.getFhirContext();
373        }
374
375        @Override
376        public IHttpClient getHttpClient() {
377                return myDelegate.getHttpClient();
378        }
379
380        @Override
381        public String getServerBase() {
382                return myDelegate.getServerBase();
383        }
384
385        @Override
386        public void setPrettyPrint(Boolean thePrettyPrint) {
387                myDelegate.setPrettyPrint(thePrettyPrint);
388        }
389
390        @Override
391        public void setSummary(SummaryEnum theSummary) {
392                myDelegate.setSummary(theSummary);
393        }
394
395        @Override
396        public void setFormatParamStyle(RequestFormatParamStyleEnum theRequestFormatParamStyle) {
397                myDelegate.setFormatParamStyle(theRequestFormatParamStyle);
398        }
399}