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}