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}