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