
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.dao.tx.IHapiTransactionService; 033import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc; 034import ca.uhn.fhir.jpa.provider.IReplaceReferencesSvc; 035import ca.uhn.fhir.merge.MergeProvenanceSvc; 036import ca.uhn.fhir.replacereferences.ReplaceReferencesRequest; 037import ca.uhn.fhir.rest.api.server.RequestDetails; 038import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException; 039import ca.uhn.fhir.util.OperationOutcomeUtil; 040import ca.uhn.fhir.util.ParametersUtil; 041import org.hl7.fhir.instance.model.api.IBase; 042import org.hl7.fhir.instance.model.api.IBaseOperationOutcome; 043import org.hl7.fhir.instance.model.api.IBaseParameters; 044import org.hl7.fhir.r4.model.Bundle; 045import org.hl7.fhir.r4.model.Patient; 046import org.hl7.fhir.r4.model.Task; 047import org.slf4j.Logger; 048import org.slf4j.LoggerFactory; 049 050import java.util.Date; 051import java.util.List; 052 053import static ca.uhn.fhir.batch2.jobs.merge.MergeAppCtx.JOB_MERGE; 054import static ca.uhn.fhir.rest.api.Constants.STATUS_HTTP_200_OK; 055import static ca.uhn.fhir.rest.api.Constants.STATUS_HTTP_202_ACCEPTED; 056import static ca.uhn.fhir.rest.api.Constants.STATUS_HTTP_500_INTERNAL_ERROR; 057import static ca.uhn.fhir.rest.server.provider.ProviderConstants.OPERATION_REPLACE_REFERENCES_OUTPUT_PARAM_OUTCOME; 058 059/** 060 * Service for the FHIR $merge operation. Currently only supports Patient/$merge. The plan is to expand to other resource types. 061 */ 062public class ResourceMergeService { 063 private static final Logger ourLog = LoggerFactory.getLogger(ResourceMergeService.class); 064 065 private final FhirContext myFhirContext; 066 private final JpaStorageSettings myStorageSettings; 067 private final IFhirResourceDao<Patient> myPatientDao; 068 private final IReplaceReferencesSvc myReplaceReferencesSvc; 069 private final IHapiTransactionService myHapiTransactionService; 070 private final IRequestPartitionHelperSvc myRequestPartitionHelperSvc; 071 private final IFhirResourceDao<Task> myTaskDao; 072 private final IJobCoordinator myJobCoordinator; 073 private final MergeResourceHelper myMergeResourceHelper; 074 private final Batch2TaskHelper myBatch2TaskHelper; 075 private final MergeValidationService myMergeValidationService; 076 private final MergeProvenanceSvc myMergeProvenanceSvc; 077 078 public ResourceMergeService( 079 JpaStorageSettings theStorageSettings, 080 DaoRegistry theDaoRegistry, 081 IReplaceReferencesSvc theReplaceReferencesSvc, 082 IHapiTransactionService theHapiTransactionService, 083 IRequestPartitionHelperSvc theRequestPartitionHelperSvc, 084 IJobCoordinator theJobCoordinator, 085 Batch2TaskHelper theBatch2TaskHelper) { 086 myStorageSettings = theStorageSettings; 087 088 myPatientDao = theDaoRegistry.getResourceDao(Patient.class); 089 myTaskDao = theDaoRegistry.getResourceDao(Task.class); 090 myReplaceReferencesSvc = theReplaceReferencesSvc; 091 myRequestPartitionHelperSvc = theRequestPartitionHelperSvc; 092 myJobCoordinator = theJobCoordinator; 093 myBatch2TaskHelper = theBatch2TaskHelper; 094 myFhirContext = myPatientDao.getContext(); 095 myHapiTransactionService = theHapiTransactionService; 096 myMergeProvenanceSvc = new MergeProvenanceSvc(theDaoRegistry); 097 myMergeResourceHelper = new MergeResourceHelper(theDaoRegistry, myMergeProvenanceSvc); 098 myMergeValidationService = new MergeValidationService(myFhirContext, theDaoRegistry); 099 } 100 101 /** 102 * Perform the $merge operation. Operation can be performed synchronously or asynchronously depending on 103 * the prefer-async request header. 104 * If the operation is requested to be performed synchronously and the number of 105 * resources to be changed exceeds the provided batch size, 106 * and error is returned indicating that operation needs to be performed asynchronously. See the 107 * <a href="https://build.fhir.org/patient-operation-merge.html">Patient $merge spec</a> 108 * for details on what the difference is between synchronous and asynchronous mode. 109 * 110 * @param theMergeOperationParameters the merge operation parameters 111 * @param theRequestDetails the request details 112 * @return the merge outcome containing OperationOutcome and HTTP status code 113 */ 114 public MergeOperationOutcome merge( 115 BaseMergeOperationInputParameters theMergeOperationParameters, RequestDetails theRequestDetails) { 116 117 MergeOperationOutcome mergeOutcome = new MergeOperationOutcome(); 118 IBaseOperationOutcome operationOutcome = OperationOutcomeUtil.newInstance(myFhirContext); 119 mergeOutcome.setOperationOutcome(operationOutcome); 120 // default to 200 OK, would be changed to another code during processing as required 121 mergeOutcome.setHttpStatusCode(STATUS_HTTP_200_OK); 122 try { 123 validateAndMerge(theMergeOperationParameters, theRequestDetails, mergeOutcome); 124 } catch (Exception e) { 125 ourLog.error("Resource merge failed", e); 126 if (e instanceof BaseServerResponseException) { 127 mergeOutcome.setHttpStatusCode(((BaseServerResponseException) e).getStatusCode()); 128 } else { 129 mergeOutcome.setHttpStatusCode(STATUS_HTTP_500_INTERNAL_ERROR); 130 } 131 OperationOutcomeUtil.addIssue(myFhirContext, operationOutcome, "error", e.getMessage(), null, "exception"); 132 } 133 return mergeOutcome; 134 } 135 136 private void validateAndMerge( 137 BaseMergeOperationInputParameters theMergeOperationParameters, 138 RequestDetails theRequestDetails, 139 MergeOperationOutcome theMergeOutcome) { 140 141 // TODO KHS remove the outparameter and instead accumulate issues in the validation result 142 MergeValidationResult mergeValidationResult = 143 myMergeValidationService.validate(theMergeOperationParameters, theRequestDetails, theMergeOutcome); 144 145 if (mergeValidationResult.isValid) { 146 Patient sourceResource = mergeValidationResult.sourceResource; 147 Patient targetResource = mergeValidationResult.targetResource; 148 149 if (theMergeOperationParameters.getPreview()) { 150 handlePreview( 151 sourceResource, 152 targetResource, 153 theMergeOperationParameters, 154 theRequestDetails, 155 theMergeOutcome); 156 } else { 157 doMerge( 158 theMergeOperationParameters, 159 sourceResource, 160 targetResource, 161 theRequestDetails, 162 theMergeOutcome); 163 } 164 } else { 165 theMergeOutcome.setHttpStatusCode(mergeValidationResult.httpStatusCode); 166 } 167 } 168 169 private void handlePreview( 170 Patient theSourceResource, 171 Patient theTargetResource, 172 BaseMergeOperationInputParameters theMergeOperationParameters, 173 RequestDetails theRequestDetails, 174 MergeOperationOutcome theMergeOutcome) { 175 176 Integer referencingResourceCount = myReplaceReferencesSvc.countResourcesReferencingResource( 177 theSourceResource.getIdElement().toVersionless(), theRequestDetails); 178 179 // in preview mode, we should also return what the target would look like 180 Patient theResultResource = (Patient) theMergeOperationParameters.getResultResource(); 181 Patient targetPatientAsIfUpdated = myMergeResourceHelper.prepareTargetPatientForUpdate( 182 theTargetResource, theSourceResource, theResultResource, theMergeOperationParameters.getDeleteSource()); 183 theMergeOutcome.setUpdatedTargetResource(targetPatientAsIfUpdated); 184 185 // adding +2 because the source and the target resources would be updated as well 186 String diagnosticsMsg = String.format("Merge would update %d resources", referencingResourceCount + 2); 187 String detailsText = "Preview only merge operation - no issues detected"; 188 addInfoToOperationOutcome(theMergeOutcome.getOperationOutcome(), diagnosticsMsg, detailsText); 189 } 190 191 private void doMerge( 192 BaseMergeOperationInputParameters theMergeOperationParameters, 193 Patient theSourceResource, 194 Patient theTargetResource, 195 RequestDetails theRequestDetails, 196 MergeOperationOutcome theMergeOutcome) { 197 198 RequestPartitionId partitionId = myRequestPartitionHelperSvc.determineReadPartitionForRequest( 199 theRequestDetails, ReadPartitionIdRequestDetails.forRead(theTargetResource.getIdElement())); 200 201 if (theRequestDetails.isPreferAsync()) { 202 doMergeAsync( 203 theMergeOperationParameters, 204 theSourceResource, 205 theTargetResource, 206 theRequestDetails, 207 theMergeOutcome, 208 partitionId); 209 } else { 210 doMergeSync( 211 theMergeOperationParameters, 212 theSourceResource, 213 theTargetResource, 214 theRequestDetails, 215 theMergeOutcome, 216 partitionId); 217 } 218 } 219 220 private void doMergeSync( 221 BaseMergeOperationInputParameters theMergeOperationParameters, 222 Patient theSourceResource, 223 Patient theTargetResource, 224 RequestDetails theRequestDetails, 225 MergeOperationOutcome theMergeOutcome, 226 RequestPartitionId partitionId) { 227 228 Date startTime = new Date(); 229 ReplaceReferencesRequest replaceReferencesRequest = new ReplaceReferencesRequest( 230 theSourceResource.getIdElement(), 231 theTargetResource.getIdElement(), 232 theMergeOperationParameters.getResourceLimit(), 233 partitionId, 234 // don't create provenance as part of replace-references, 235 // we create it after updating source and target for merge 236 false, 237 null); 238 239 IBaseParameters outParams = 240 myReplaceReferencesSvc.replaceReferences(replaceReferencesRequest, theRequestDetails); 241 242 Bundle patchResultBundle = (Bundle) ParametersUtil.getNamedParameterResource( 243 myFhirContext, outParams, OPERATION_REPLACE_REFERENCES_OUTPUT_PARAM_OUTCOME) 244 .orElseThrow(); 245 246 myHapiTransactionService.withRequest(theRequestDetails).execute(() -> { 247 Patient updatedTarget = myMergeResourceHelper.updateMergedResourcesAfterReferencesReplaced( 248 theSourceResource, 249 theTargetResource, 250 (Patient) theMergeOperationParameters.getResultResource(), 251 theMergeOperationParameters.getDeleteSource(), 252 theRequestDetails); 253 254 theMergeOutcome.setUpdatedTargetResource(updatedTarget); 255 256 if (theMergeOperationParameters.getCreateProvenance()) { 257 myMergeResourceHelper.createProvenance( 258 theSourceResource, 259 updatedTarget, 260 List.of(patchResultBundle), 261 theMergeOperationParameters.getDeleteSource(), 262 theRequestDetails, 263 startTime, 264 theMergeOperationParameters.getProvenanceAgents()); 265 } 266 }); 267 268 String detailsText = "Merge operation completed successfully."; 269 addInfoToOperationOutcome(theMergeOutcome.getOperationOutcome(), null, detailsText); 270 } 271 272 private void doMergeAsync( 273 BaseMergeOperationInputParameters theMergeOperationParameters, 274 Patient theSourceResource, 275 Patient theTargetResource, 276 RequestDetails theRequestDetails, 277 MergeOperationOutcome theMergeOutcome, 278 RequestPartitionId thePartitionId) { 279 280 MergeJobParameters mergeJobParameters = theMergeOperationParameters.asMergeJobParameters( 281 myFhirContext, myStorageSettings, theSourceResource, theTargetResource, thePartitionId); 282 283 Task task = myBatch2TaskHelper.startJobAndCreateAssociatedTask( 284 myTaskDao, theRequestDetails, myJobCoordinator, JOB_MERGE, mergeJobParameters); 285 286 task.setIdElement(task.getIdElement().toUnqualifiedVersionless()); 287 task.getMeta().setVersionId(null); 288 theMergeOutcome.setTask(task); 289 theMergeOutcome.setHttpStatusCode(STATUS_HTTP_202_ACCEPTED); 290 291 String detailsText = "Merge request is accepted, and will be processed asynchronously. See" 292 + " task resource returned in this response for details."; 293 addInfoToOperationOutcome(theMergeOutcome.getOperationOutcome(), null, detailsText); 294 } 295 296 private void addInfoToOperationOutcome( 297 IBaseOperationOutcome theOutcome, String theDiagnosticMsg, String theDetailsText) { 298 IBase issue = 299 OperationOutcomeUtil.addIssue(myFhirContext, theOutcome, "information", theDiagnosticMsg, null, null); 300 OperationOutcomeUtil.addDetailsToIssue(myFhirContext, issue, null, null, theDetailsText); 301 } 302}