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}