001package ca.uhn.fhir.jpa.bulk.export.provider; 002 003/*- 004 * #%L 005 * HAPI FHIR Storage api 006 * %% 007 * Copyright (C) 2014 - 2022 Smile CDR, Inc. 008 * %% 009 * Licensed under the Apache License, Version 2.0 (the "License"); 010 * you may not use this file except in compliance with the License. 011 * You may obtain a copy of the License at 012 * 013 * http://www.apache.org/licenses/LICENSE-2.0 014 * 015 * Unless required by applicable law or agreed to in writing, software 016 * distributed under the License is distributed on an "AS IS" BASIS, 017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 018 * See the License for the specific language governing permissions and 019 * limitations under the License. 020 * #L% 021 */ 022 023import ca.uhn.fhir.i18n.Msg; 024import ca.uhn.fhir.context.FhirContext; 025import ca.uhn.fhir.jpa.bulk.export.api.IBulkDataExportSvc; 026import ca.uhn.fhir.jpa.bulk.export.model.BulkExportResponseJson; 027import ca.uhn.fhir.jpa.model.util.JpaConstants; 028import ca.uhn.fhir.rest.annotation.IdParam; 029import ca.uhn.fhir.rest.annotation.Operation; 030import ca.uhn.fhir.rest.annotation.OperationParam; 031import ca.uhn.fhir.rest.api.CacheControlDirective; 032import ca.uhn.fhir.rest.api.Constants; 033import ca.uhn.fhir.rest.api.PreferHeader; 034import ca.uhn.fhir.rest.api.server.bulk.BulkDataExportOptions; 035import ca.uhn.fhir.rest.server.RestfulServerUtils; 036import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; 037import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; 038import ca.uhn.fhir.util.ArrayUtil; 039import ca.uhn.fhir.util.JsonUtil; 040import ca.uhn.fhir.util.OperationOutcomeUtil; 041import com.google.common.annotations.VisibleForTesting; 042import org.apache.commons.lang3.StringUtils; 043import org.hl7.fhir.instance.model.api.IBaseOperationOutcome; 044import org.hl7.fhir.instance.model.api.IIdType; 045import org.hl7.fhir.instance.model.api.IPrimitiveType; 046import org.hl7.fhir.r4.model.InstantType; 047import org.slf4j.Logger; 048import org.springframework.beans.factory.annotation.Autowired; 049 050import javax.servlet.http.HttpServletResponse; 051import java.io.IOException; 052import java.util.Arrays; 053import java.util.Date; 054import java.util.HashSet; 055import java.util.List; 056import java.util.Set; 057import java.util.stream.Collectors; 058 059import static org.slf4j.LoggerFactory.getLogger; 060 061 062public class BulkDataExportProvider { 063 public static final String FARM_TO_TABLE_TYPE_FILTER_REGEX = "(?:,)(?=[A-Z][a-z]+\\?)"; 064 private static final Logger ourLog = getLogger(BulkDataExportProvider.class); 065 066 @Autowired 067 private IBulkDataExportSvc myBulkDataExportSvc; 068 @Autowired 069 private FhirContext myFhirContext; 070 071 @VisibleForTesting 072 public void setFhirContextForUnitTest(FhirContext theFhirContext) { 073 myFhirContext = theFhirContext; 074 } 075 076 @VisibleForTesting 077 public void setBulkDataExportSvcForUnitTests(IBulkDataExportSvc theBulkDataExportSvc) { 078 myBulkDataExportSvc = theBulkDataExportSvc; 079 } 080 081 /** 082 * $export 083 */ 084 @Operation(name = JpaConstants.OPERATION_EXPORT, global = false /* set to true once we can handle this */, manualResponse = true, idempotent = true) 085 public void export( 086 @OperationParam(name = JpaConstants.PARAM_EXPORT_OUTPUT_FORMAT, min = 0, max = 1, typeName = "string") IPrimitiveType<String> theOutputFormat, 087 @OperationParam(name = JpaConstants.PARAM_EXPORT_TYPE, min = 0, max = 1, typeName = "string") IPrimitiveType<String> theType, 088 @OperationParam(name = JpaConstants.PARAM_EXPORT_SINCE, min = 0, max = 1, typeName = "instant") IPrimitiveType<Date> theSince, 089 @OperationParam(name = JpaConstants.PARAM_EXPORT_TYPE_FILTER, min = 0, max = OperationParam.MAX_UNLIMITED, typeName = "string") List<IPrimitiveType<String>> theTypeFilter, 090 ServletRequestDetails theRequestDetails 091 ) { 092 validatePreferAsyncHeader(theRequestDetails); 093 BulkDataExportOptions bulkDataExportOptions = buildSystemBulkExportOptions(theOutputFormat, theType, theSince, theTypeFilter); 094 Boolean useCache = shouldUseCache(theRequestDetails); 095 IBulkDataExportSvc.JobInfo outcome = myBulkDataExportSvc.submitJob(bulkDataExportOptions, useCache, theRequestDetails); 096 writePollingLocationToResponseHeaders(theRequestDetails, outcome); 097 } 098 099 private boolean shouldUseCache(ServletRequestDetails theRequestDetails) { 100 CacheControlDirective cacheControlDirective = new CacheControlDirective().parse(theRequestDetails.getHeaders(Constants.HEADER_CACHE_CONTROL)); 101 return !cacheControlDirective.isNoCache(); 102 } 103 104 private String getServerBase(ServletRequestDetails theRequestDetails) { 105 return StringUtils.removeEnd(theRequestDetails.getServerBaseForRequest(), "/"); 106 } 107 108 /** 109 * Group/Id/$export 110 */ 111 @Operation(name = JpaConstants.OPERATION_EXPORT, manualResponse = true, idempotent = true, typeName = "Group") 112 public void groupExport( 113 @IdParam IIdType theIdParam, 114 @OperationParam(name = JpaConstants.PARAM_EXPORT_OUTPUT_FORMAT, min = 0, max = 1, typeName = "string") IPrimitiveType<String> theOutputFormat, 115 @OperationParam(name = JpaConstants.PARAM_EXPORT_TYPE, min = 0, max = 1, typeName = "string") IPrimitiveType<String> theType, 116 @OperationParam(name = JpaConstants.PARAM_EXPORT_SINCE, min = 0, max = 1, typeName = "instant") IPrimitiveType<Date> theSince, 117 @OperationParam(name = JpaConstants.PARAM_EXPORT_TYPE_FILTER, min = 0, max = OperationParam.MAX_UNLIMITED, typeName = "string") List<IPrimitiveType<String>> theTypeFilter, 118 @OperationParam(name = JpaConstants.PARAM_EXPORT_MDM, min = 0, max = 1, typeName = "boolean") IPrimitiveType<Boolean> theMdm, 119 ServletRequestDetails theRequestDetails 120 ) { 121 ourLog.debug("Received Group Bulk Export Request for Group {}", theIdParam); 122 ourLog.debug("_type={}", theIdParam); 123 ourLog.debug("_since={}", theSince); 124 ourLog.debug("_typeFilter={}", theTypeFilter); 125 ourLog.debug("_mdm=", theMdm); 126 127 128 validatePreferAsyncHeader(theRequestDetails); 129 BulkDataExportOptions bulkDataExportOptions = buildGroupBulkExportOptions(theOutputFormat, theType, theSince, theTypeFilter, theIdParam, theMdm); 130 validateResourceTypesAllContainPatientSearchParams(bulkDataExportOptions.getResourceTypes()); 131 IBulkDataExportSvc.JobInfo outcome = myBulkDataExportSvc.submitJob(bulkDataExportOptions, shouldUseCache(theRequestDetails), null); 132 writePollingLocationToResponseHeaders(theRequestDetails, outcome); 133 } 134 135 private void validateResourceTypesAllContainPatientSearchParams(Set<String> theResourceTypes) { 136 if (theResourceTypes != null) { 137 List<String> badResourceTypes = theResourceTypes.stream() 138 .filter(resourceType -> !myBulkDataExportSvc.getPatientCompartmentResources().contains(resourceType)) 139 .collect(Collectors.toList()); 140 141 if (!badResourceTypes.isEmpty()) { 142 throw new InvalidRequestException(Msg.code(512) + String.format("Resource types [%s] are invalid for this type of export, as they do not contain search parameters that refer to patients.", String.join(",", badResourceTypes))); 143 } 144 } 145 } 146 147 /** 148 * Patient/$export 149 */ 150 @Operation(name = JpaConstants.OPERATION_EXPORT, manualResponse = true, idempotent = true, typeName = "Patient") 151 public void patientExport( 152 @OperationParam(name = JpaConstants.PARAM_EXPORT_OUTPUT_FORMAT, min = 0, max = 1, typeName = "string") IPrimitiveType<String> theOutputFormat, 153 @OperationParam(name = JpaConstants.PARAM_EXPORT_TYPE, min = 0, max = 1, typeName = "string") IPrimitiveType<String> theType, 154 @OperationParam(name = JpaConstants.PARAM_EXPORT_SINCE, min = 0, max = 1, typeName = "instant") IPrimitiveType<Date> theSince, 155 @OperationParam(name = JpaConstants.PARAM_EXPORT_TYPE_FILTER, min = 0, max = OperationParam.MAX_UNLIMITED, typeName = "string") List<IPrimitiveType<String>> theTypeFilter, 156 ServletRequestDetails theRequestDetails 157 ) { 158 validatePreferAsyncHeader(theRequestDetails); 159 BulkDataExportOptions bulkDataExportOptions = buildPatientBulkExportOptions(theOutputFormat, theType, theSince, theTypeFilter); 160 validateResourceTypesAllContainPatientSearchParams(bulkDataExportOptions.getResourceTypes()); 161 IBulkDataExportSvc.JobInfo outcome = myBulkDataExportSvc.submitJob(bulkDataExportOptions, shouldUseCache(theRequestDetails), null); 162 writePollingLocationToResponseHeaders(theRequestDetails, outcome); 163 } 164 165 /** 166 * $export-poll-status 167 */ 168 @Operation(name = JpaConstants.OPERATION_EXPORT_POLL_STATUS, manualResponse = true, idempotent = true) 169 public void exportPollStatus( 170 @OperationParam(name = JpaConstants.PARAM_EXPORT_POLL_STATUS_JOB_ID, typeName = "string", min = 0, max = 1) IPrimitiveType<String> theJobId, 171 ServletRequestDetails theRequestDetails 172 ) throws IOException { 173 174 HttpServletResponse response = theRequestDetails.getServletResponse(); 175 theRequestDetails.getServer().addHeadersToResponse(response); 176 177 IBulkDataExportSvc.JobInfo status = myBulkDataExportSvc.getJobInfoOrThrowResourceNotFound(theJobId.getValueAsString()); 178 179 switch (status.getStatus()) { 180 case SUBMITTED: 181 case BUILDING: 182 183 response.setStatus(Constants.STATUS_HTTP_202_ACCEPTED); 184 response.addHeader(Constants.HEADER_X_PROGRESS, "Build in progress - Status set to " + status.getStatus() + " at " + new InstantType(status.getStatusTime()).getValueAsString()); 185 response.addHeader(Constants.HEADER_RETRY_AFTER, "120"); 186 break; 187 188 case COMPLETE: 189 190 response.setStatus(Constants.STATUS_HTTP_200_OK); 191 response.setContentType(Constants.CT_JSON); 192 193 // Create a JSON response 194 BulkExportResponseJson bulkResponseDocument = new BulkExportResponseJson(); 195 bulkResponseDocument.setTransactionTime(status.getStatusTime()); 196 bulkResponseDocument.setRequest(status.getRequest()); 197 for (IBulkDataExportSvc.FileEntry nextFile : status.getFiles()) { 198 String serverBase = getServerBase(theRequestDetails); 199 String nextUrl = serverBase + "/" + nextFile.getResourceId().toUnqualifiedVersionless().getValue(); 200 bulkResponseDocument 201 .addOutput() 202 .setType(nextFile.getResourceType()) 203 .setUrl(nextUrl); 204 } 205 JsonUtil.serialize(bulkResponseDocument, response.getWriter()); 206 response.getWriter().close(); 207 break; 208 209 case ERROR: 210 211 response.setStatus(Constants.STATUS_HTTP_500_INTERNAL_ERROR); 212 response.setContentType(Constants.CT_FHIR_JSON); 213 214 // Create an OperationOutcome response 215 IBaseOperationOutcome oo = OperationOutcomeUtil.newInstance(myFhirContext); 216 OperationOutcomeUtil.addIssue(myFhirContext, oo, "error", status.getStatusMessage(), null, null); 217 myFhirContext.newJsonParser().setPrettyPrint(true).encodeResourceToWriter(oo, response.getWriter()); 218 response.getWriter().close(); 219 } 220 } 221 222 private BulkDataExportOptions buildSystemBulkExportOptions(IPrimitiveType<String> theOutputFormat, IPrimitiveType<String> theType, IPrimitiveType<Date> theSince, List<IPrimitiveType<String>> theTypeFilter) { 223 return buildBulkDataExportOptions(theOutputFormat, theType, theSince, theTypeFilter, BulkDataExportOptions.ExportStyle.SYSTEM); 224 } 225 226 private BulkDataExportOptions buildGroupBulkExportOptions(IPrimitiveType<String> theOutputFormat, IPrimitiveType<String> theType, IPrimitiveType<Date> theSince, List<IPrimitiveType<String>> theTypeFilter, IIdType theGroupId, IPrimitiveType<Boolean> theExpandMdm) { 227 BulkDataExportOptions bulkDataExportOptions = buildBulkDataExportOptions(theOutputFormat, theType, theSince, theTypeFilter, BulkDataExportOptions.ExportStyle.GROUP); 228 bulkDataExportOptions.setGroupId(theGroupId); 229 230 boolean mdm = false; 231 if (theExpandMdm != null) { 232 mdm = theExpandMdm.getValue(); 233 } 234 bulkDataExportOptions.setExpandMdm(mdm); 235 236 return bulkDataExportOptions; 237 } 238 239 private BulkDataExportOptions buildPatientBulkExportOptions(IPrimitiveType<String> theOutputFormat, IPrimitiveType<String> theType, IPrimitiveType<Date> theSince, List<IPrimitiveType<String>> theTypeFilter) { 240 return buildBulkDataExportOptions(theOutputFormat, theType, theSince, theTypeFilter, BulkDataExportOptions.ExportStyle.PATIENT); 241 } 242 243 private BulkDataExportOptions buildBulkDataExportOptions(IPrimitiveType<String> theOutputFormat, IPrimitiveType<String> theType, IPrimitiveType<Date> theSince, List<IPrimitiveType<String>> theTypeFilter, BulkDataExportOptions.ExportStyle theExportStyle) { 244 String outputFormat = theOutputFormat != null ? theOutputFormat.getValueAsString() : null; 245 246 Set<String> resourceTypes = null; 247 if (theType != null) { 248 resourceTypes = ArrayUtil.commaSeparatedListToCleanSet(theType.getValueAsString()); 249 } 250 251 Date since = null; 252 if (theSince != null) { 253 since = theSince.getValue(); 254 } 255 256 Set<String> typeFilters = splitTypeFilters(theTypeFilter); 257 258 BulkDataExportOptions bulkDataExportOptions = new BulkDataExportOptions(); 259 bulkDataExportOptions.setFilters(typeFilters); 260 bulkDataExportOptions.setExportStyle(theExportStyle); 261 bulkDataExportOptions.setSince(since); 262 bulkDataExportOptions.setResourceTypes(resourceTypes); 263 bulkDataExportOptions.setOutputFormat(outputFormat); 264 return bulkDataExportOptions; 265 } 266 267 public void writePollingLocationToResponseHeaders(ServletRequestDetails theRequestDetails, IBulkDataExportSvc.JobInfo theOutcome) { 268 String serverBase = getServerBase(theRequestDetails); 269 String pollLocation = serverBase + "/" + JpaConstants.OPERATION_EXPORT_POLL_STATUS + "?" + JpaConstants.PARAM_EXPORT_POLL_STATUS_JOB_ID + "=" + theOutcome.getJobId(); 270 271 HttpServletResponse response = theRequestDetails.getServletResponse(); 272 273 // Add standard headers 274 theRequestDetails.getServer().addHeadersToResponse(response); 275 276 // Successful 202 Accepted 277 response.addHeader(Constants.HEADER_CONTENT_LOCATION, pollLocation); 278 response.setStatus(Constants.STATUS_HTTP_202_ACCEPTED); 279 } 280 281 private void validatePreferAsyncHeader(ServletRequestDetails theRequestDetails) { 282 String preferHeader = theRequestDetails.getHeader(Constants.HEADER_PREFER); 283 PreferHeader prefer = RestfulServerUtils.parsePreferHeader(null, preferHeader); 284 if (prefer.getRespondAsync() == false) { 285 throw new InvalidRequestException(Msg.code(513) + "Must request async processing for $export"); 286 } 287 } 288 289 private Set<String> splitTypeFilters(List<IPrimitiveType<String>> theTypeFilter) { 290 if (theTypeFilter== null) { 291 return null; 292 } 293 294 Set<String> retVal = new HashSet<>(); 295 296 for (IPrimitiveType<String> next : theTypeFilter) { 297 String typeFilterString = next.getValueAsString(); 298 Arrays 299 .stream(typeFilterString.split(FARM_TO_TABLE_TYPE_FILTER_REGEX)) 300 .filter(StringUtils::isNotBlank) 301 .forEach(t->retVal.add(t)); 302 } 303 304 return retVal; 305 } 306 307}