
001/*- 002 * #%L 003 * HAPI FHIR JPA Server 004 * %% 005 * Copyright (C) 2014 - 2025 Smile CDR, Inc. 006 * %% 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * You may obtain a copy of the License at 010 * 011 * http://www.apache.org/licenses/LICENSE-2.0 012 * 013 * Unless required by applicable law or agreed to in writing, software 014 * distributed under the License is distributed on an "AS IS" BASIS, 015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 016 * See the License for the specific language governing permissions and 017 * limitations under the License. 018 * #L% 019 */ 020package ca.uhn.fhir.jpa.provider.merge; 021 022import ca.uhn.fhir.batch2.api.IJobCoordinator; 023import ca.uhn.fhir.batch2.jobs.merge.MergeJobParameters; 024import ca.uhn.fhir.batch2.jobs.merge.MergeResourceHelper; 025import ca.uhn.fhir.batch2.util.Batch2TaskHelper; 026import ca.uhn.fhir.context.FhirContext; 027import ca.uhn.fhir.interceptor.model.ReadPartitionIdRequestDetails; 028import ca.uhn.fhir.interceptor.model.RequestPartitionId; 029import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; 030import ca.uhn.fhir.jpa.api.dao.DaoRegistry; 031import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; 032import ca.uhn.fhir.jpa.api.model.DaoMethodOutcome; 033import ca.uhn.fhir.jpa.dao.tx.IHapiTransactionService; 034import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc; 035import ca.uhn.fhir.jpa.provider.IReplaceReferencesSvc; 036import ca.uhn.fhir.merge.MergeProvenanceSvc; 037import ca.uhn.fhir.replacereferences.ReplaceReferencesRequest; 038import ca.uhn.fhir.rest.api.server.RequestDetails; 039import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException; 040import ca.uhn.fhir.util.OperationOutcomeUtil; 041import ca.uhn.fhir.util.ParametersUtil; 042import org.hl7.fhir.instance.model.api.IBaseOperationOutcome; 043import org.hl7.fhir.instance.model.api.IBaseParameters; 044import org.hl7.fhir.instance.model.api.IBaseResource; 045import org.hl7.fhir.r4.model.Bundle; 046import org.hl7.fhir.r4.model.Patient; 047import org.hl7.fhir.r4.model.Task; 048import org.slf4j.Logger; 049import org.slf4j.LoggerFactory; 050 051import java.util.Date; 052import java.util.List; 053 054import static ca.uhn.fhir.batch2.jobs.merge.MergeAppCtx.JOB_MERGE; 055import static ca.uhn.fhir.batch2.jobs.merge.MergeResourceHelper.addInfoToOperationOutcome; 056import static ca.uhn.fhir.rest.api.Constants.STATUS_HTTP_200_OK; 057import static ca.uhn.fhir.rest.api.Constants.STATUS_HTTP_202_ACCEPTED; 058import static ca.uhn.fhir.rest.api.Constants.STATUS_HTTP_500_INTERNAL_ERROR; 059import static ca.uhn.fhir.rest.server.provider.ProviderConstants.OPERATION_REPLACE_REFERENCES_OUTPUT_PARAM_OUTCOME; 060 061/** 062 * Service for the FHIR $merge operation. Currently only supports Patient/$merge. The plan is to expand to other resource types. 063 */ 064public class ResourceMergeService { 065 private static final Logger ourLog = LoggerFactory.getLogger(ResourceMergeService.class); 066 067 private final FhirContext myFhirContext; 068 private final JpaStorageSettings myStorageSettings; 069 private final IFhirResourceDao<Patient> myPatientDao; 070 private final IReplaceReferencesSvc myReplaceReferencesSvc; 071 private final IHapiTransactionService myHapiTransactionService; 072 private final IRequestPartitionHelperSvc myRequestPartitionHelperSvc; 073 private final IFhirResourceDao<Task> myTaskDao; 074 private final IJobCoordinator myJobCoordinator; 075 private final MergeResourceHelper myMergeResourceHelper; 076 private final Batch2TaskHelper myBatch2TaskHelper; 077 private final MergeValidationService myMergeValidationService; 078 private final MergeProvenanceSvc myMergeProvenanceSvc; 079 080 public ResourceMergeService( 081 JpaStorageSettings theStorageSettings, 082 DaoRegistry theDaoRegistry, 083 IReplaceReferencesSvc theReplaceReferencesSvc, 084 IHapiTransactionService theHapiTransactionService, 085 IRequestPartitionHelperSvc theRequestPartitionHelperSvc, 086 IJobCoordinator theJobCoordinator, 087 Batch2TaskHelper theBatch2TaskHelper, 088 MergeValidationService theMergeValidationService, 089 MergeProvenanceSvc theMergeProvenanceSvc) { 090 myStorageSettings = theStorageSettings; 091 092 myPatientDao = theDaoRegistry.getResourceDao(Patient.class); 093 myTaskDao = theDaoRegistry.getResourceDao(Task.class); 094 myReplaceReferencesSvc = theReplaceReferencesSvc; 095 myRequestPartitionHelperSvc = theRequestPartitionHelperSvc; 096 myJobCoordinator = theJobCoordinator; 097 myBatch2TaskHelper = theBatch2TaskHelper; 098 myFhirContext = myPatientDao.getContext(); 099 myHapiTransactionService = theHapiTransactionService; 100 myMergeProvenanceSvc = theMergeProvenanceSvc; 101 myMergeResourceHelper = new MergeResourceHelper(theDaoRegistry, myMergeProvenanceSvc); 102 myMergeValidationService = theMergeValidationService; 103 } 104 105 /** 106 * Perform the $merge operation. Operation can be performed synchronously or asynchronously depending on 107 * the prefer-async request header. 108 * If the operation is requested to be performed synchronously and the number of 109 * resources to be changed exceeds the provided batch size, 110 * and error is returned indicating that operation needs to be performed asynchronously. See the 111 * <a href="https://build.fhir.org/patient-operation-merge.html">Patient $merge spec</a> 112 * for details on what the difference is between synchronous and asynchronous mode. 113 * 114 * @param theMergeOperationParameters the merge operation parameters 115 * @param theRequestDetails the request details 116 * @return the merge outcome containing OperationOutcome and HTTP status code 117 */ 118 public MergeOperationOutcome merge( 119 MergeOperationInputParameters theMergeOperationParameters, RequestDetails theRequestDetails) { 120 121 MergeOperationOutcome mergeOutcome = new MergeOperationOutcome(); 122 IBaseOperationOutcome operationOutcome = OperationOutcomeUtil.newInstance(myFhirContext); 123 mergeOutcome.setOperationOutcome(operationOutcome); 124 // default to 200 OK, would be changed to another code during processing as required 125 mergeOutcome.setHttpStatusCode(STATUS_HTTP_200_OK); 126 try { 127 validateAndMerge(theMergeOperationParameters, theRequestDetails, mergeOutcome); 128 } catch (Exception e) { 129 ourLog.error("Resource merge failed", e); 130 if (e instanceof BaseServerResponseException) { 131 mergeOutcome.setHttpStatusCode(((BaseServerResponseException) e).getStatusCode()); 132 } else { 133 mergeOutcome.setHttpStatusCode(STATUS_HTTP_500_INTERNAL_ERROR); 134 } 135 OperationOutcomeUtil.addIssue(myFhirContext, operationOutcome, "error", e.getMessage(), null, "exception"); 136 } 137 return mergeOutcome; 138 } 139 140 private void validateAndMerge( 141 MergeOperationInputParameters theMergeOperationParameters, 142 RequestDetails theRequestDetails, 143 MergeOperationOutcome theMergeOutcome) { 144 145 // TODO KHS remove the outparameter and instead accumulate issues in the validation result 146 MergeValidationResult mergeValidationResult = 147 myMergeValidationService.validate(theMergeOperationParameters, theRequestDetails, theMergeOutcome); 148 149 if (mergeValidationResult.isValid) { 150 Patient sourceResource = mergeValidationResult.sourceResource; 151 Patient targetResource = mergeValidationResult.targetResource; 152 153 if (theMergeOperationParameters.getPreview()) { 154 handlePreview( 155 sourceResource, 156 targetResource, 157 theMergeOperationParameters, 158 theRequestDetails, 159 theMergeOutcome); 160 } else { 161 doMerge( 162 theMergeOperationParameters, 163 sourceResource, 164 targetResource, 165 theRequestDetails, 166 theMergeOutcome); 167 } 168 } else { 169 theMergeOutcome.setHttpStatusCode(mergeValidationResult.httpStatusCode); 170 } 171 } 172 173 private void handlePreview( 174 Patient theSourceResource, 175 Patient theTargetResource, 176 MergeOperationInputParameters theMergeOperationParameters, 177 RequestDetails theRequestDetails, 178 MergeOperationOutcome theMergeOutcome) { 179 180 Integer referencingResourceCount = myReplaceReferencesSvc.countResourcesReferencingResource( 181 theSourceResource.getIdElement().toVersionless(), theRequestDetails); 182 183 // in preview mode, we should also return what the target would look like 184 Patient theResultResource = (Patient) theMergeOperationParameters.getResultResource(); 185 Patient targetPatientAsIfUpdated = myMergeResourceHelper.prepareTargetPatientForUpdate( 186 theTargetResource, theSourceResource, theResultResource, theMergeOperationParameters.getDeleteSource()); 187 theMergeOutcome.setUpdatedTargetResource(targetPatientAsIfUpdated); 188 189 // adding +2 because the source and the target resources would be updated as well 190 String diagnosticsMsg = String.format("Merge would update %d resources", referencingResourceCount + 2); 191 String detailsText = "Preview only merge operation - no issues detected"; 192 addInfoToOperationOutcome(myFhirContext, theMergeOutcome.getOperationOutcome(), diagnosticsMsg, detailsText); 193 } 194 195 private void doMerge( 196 MergeOperationInputParameters theMergeOperationParameters, 197 Patient theSourceResource, 198 Patient theTargetResource, 199 RequestDetails theRequestDetails, 200 MergeOperationOutcome theMergeOutcome) { 201 202 RequestPartitionId partitionId = myRequestPartitionHelperSvc.determineReadPartitionForRequest( 203 theRequestDetails, ReadPartitionIdRequestDetails.forRead(theTargetResource.getIdElement())); 204 205 if (theRequestDetails.isPreferAsync()) { 206 doMergeAsync( 207 theMergeOperationParameters, 208 theSourceResource, 209 theTargetResource, 210 theRequestDetails, 211 theMergeOutcome, 212 partitionId); 213 } else { 214 doMergeSync( 215 theMergeOperationParameters, 216 theSourceResource, 217 theTargetResource, 218 theRequestDetails, 219 theMergeOutcome, 220 partitionId); 221 } 222 } 223 224 private void doMergeSync( 225 MergeOperationInputParameters theMergeOperationParameters, 226 Patient theSourceResource, 227 Patient theTargetResource, 228 RequestDetails theRequestDetails, 229 MergeOperationOutcome theMergeOutcome, 230 RequestPartitionId partitionId) { 231 232 Date startTime = new Date(); 233 ReplaceReferencesRequest replaceReferencesRequest = new ReplaceReferencesRequest( 234 theSourceResource.getIdElement(), 235 theTargetResource.getIdElement(), 236 theMergeOperationParameters.getResourceLimit(), 237 partitionId, 238 // don't create provenance as part of replace-references, 239 // we create it after updating source and target for merge 240 false, 241 null); 242 243 IBaseParameters outParams = 244 myReplaceReferencesSvc.replaceReferences(replaceReferencesRequest, theRequestDetails); 245 246 Bundle patchResultBundle = (Bundle) ParametersUtil.getNamedParameterResource( 247 myFhirContext, outParams, OPERATION_REPLACE_REFERENCES_OUTPUT_PARAM_OUTCOME) 248 .orElseThrow(); 249 250 myHapiTransactionService 251 .withRequest(theRequestDetails) 252 .withRequestPartitionId(partitionId) 253 .execute(() -> { 254 DaoMethodOutcome outcome = myMergeResourceHelper.updateMergedResourcesAfterReferencesReplaced( 255 theSourceResource, 256 theTargetResource, 257 (Patient) theMergeOperationParameters.getResultResource(), 258 theMergeOperationParameters.getDeleteSource(), 259 theRequestDetails); 260 261 Patient updatedTargetResource = (Patient) outcome.getResource(); 262 theMergeOutcome.setUpdatedTargetResource(updatedTargetResource); 263 264 if (theMergeOperationParameters.getCreateProvenance()) { 265 // we store the original input parameters and the operation outcome of updating target as 266 // contained resources in the provenance. undo-merge service uses these to contained resources. 267 List<IBaseResource> containedResources = List.of( 268 theMergeOperationParameters.getOriginalInputParameters(), 269 outcome.getOperationOutcome()); 270 271 myMergeResourceHelper.createProvenance( 272 theSourceResource, 273 updatedTargetResource, 274 List.of(patchResultBundle), 275 theMergeOperationParameters.getDeleteSource(), 276 theRequestDetails, 277 startTime, 278 theMergeOperationParameters.getProvenanceAgents(), 279 containedResources); 280 } 281 282 if (theMergeOperationParameters.getDeleteSource()) { 283 // by using an id with versionId, the delete fails if 284 // the resource was updated since we last read it 285 myPatientDao.delete(theSourceResource.getIdElement(), theRequestDetails); 286 } 287 }); 288 289 String detailsText = "Merge operation completed successfully."; 290 addInfoToOperationOutcome(myFhirContext, theMergeOutcome.getOperationOutcome(), null, detailsText); 291 } 292 293 private void doMergeAsync( 294 MergeOperationInputParameters theMergeOperationParameters, 295 Patient theSourceResource, 296 Patient theTargetResource, 297 RequestDetails theRequestDetails, 298 MergeOperationOutcome theMergeOutcome, 299 RequestPartitionId thePartitionId) { 300 301 MergeJobParameters mergeJobParameters = theMergeOperationParameters.asMergeJobParameters( 302 myFhirContext, myStorageSettings, theSourceResource, theTargetResource, thePartitionId); 303 304 Task task = myBatch2TaskHelper.startJobAndCreateAssociatedTask( 305 myTaskDao, theRequestDetails, myJobCoordinator, JOB_MERGE, mergeJobParameters); 306 307 task.setIdElement(task.getIdElement().toUnqualifiedVersionless()); 308 task.getMeta().setVersionId(null); 309 theMergeOutcome.setTask(task); 310 theMergeOutcome.setHttpStatusCode(STATUS_HTTP_202_ACCEPTED); 311 312 String detailsText = "Merge request is accepted, and will be processed asynchronously. See" 313 + " task resource returned in this response for details."; 314 addInfoToOperationOutcome(myFhirContext, theMergeOutcome.getOperationOutcome(), null, detailsText); 315 } 316}