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.context.FhirContext;
023import ca.uhn.fhir.i18n.Msg;
024import ca.uhn.fhir.interceptor.model.ReadPartitionIdRequestDetails;
025import ca.uhn.fhir.interceptor.model.RequestPartitionId;
026import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
027import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc;
028import ca.uhn.fhir.merge.MergeOperationInputParameterNames;
029import ca.uhn.fhir.merge.MergeProvenanceSvc;
030import ca.uhn.fhir.model.api.StorageResponseCodeEnum;
031import ca.uhn.fhir.replacereferences.PreviousResourceVersionRestorer;
032import ca.uhn.fhir.rest.api.server.RequestDetails;
033import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
034import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
035import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
036import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException;
037import ca.uhn.fhir.util.OperationOutcomeUtil;
038import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
039import org.hl7.fhir.instance.model.api.IIdType;
040import org.hl7.fhir.r4.model.OperationOutcome;
041import org.hl7.fhir.r4.model.Parameters;
042import org.hl7.fhir.r4.model.Patient;
043import org.hl7.fhir.r4.model.Provenance;
044import org.hl7.fhir.r4.model.Reference;
045import org.hl7.fhir.r4.model.Resource;
046import org.slf4j.Logger;
047import org.slf4j.LoggerFactory;
048
049import java.util.HashSet;
050import java.util.List;
051import java.util.Set;
052
053import static ca.uhn.fhir.batch2.jobs.merge.MergeResourceHelper.addErrorToOperationOutcome;
054import static ca.uhn.fhir.batch2.jobs.merge.MergeResourceHelper.addInfoToOperationOutcome;
055import static ca.uhn.fhir.model.api.StorageResponseCodeEnum.SUCCESSFUL_UPDATE_NO_CHANGE;
056import static ca.uhn.fhir.rest.api.Constants.STATUS_HTTP_200_OK;
057import static ca.uhn.fhir.rest.api.Constants.STATUS_HTTP_400_BAD_REQUEST;
058import static ca.uhn.fhir.rest.api.Constants.STATUS_HTTP_500_INTERNAL_ERROR;
059import static ca.uhn.fhir.rest.server.provider.ProviderConstants.OPERATION_UNDO_MERGE;
060import static java.lang.String.format;
061
062/**
063 * This service implements the $hapi.fhir.undo-merge operation.
064 * It reverts the changes made by a previous $merge operation based on the Provenance resource
065 * that was created as part of the $merge operation.
066 *
067 * Current limitations:
068 * - It fails if any resources to be restored have been subsequently changed since the `$merge` operation was performed.
069 * - It can only run synchronously.
070 * - It fails if the number of resources to restore exceeds a specified resource limit
071 * (currently set to same size as getInternalSynchronousSearchSize in JPAStorageSettings by the operation provider).
072 */
073public class ResourceUndoMergeService {
074
075        private static final Logger ourLog = LoggerFactory.getLogger(ResourceUndoMergeService.class);
076
077        private final MergeProvenanceSvc myMergeProvenanceSvc;
078        private final PreviousResourceVersionRestorer myResourceVersionRestorer;
079        private final MergeValidationService myMergeValidationService;
080        private final FhirContext myFhirContext;
081        private final IRequestPartitionHelperSvc myRequestPartitionHelperSvc;
082        private final MergeOperationInputParameterNames myInputParamNames;
083
084        public ResourceUndoMergeService(
085                        DaoRegistry theDaoRegistry,
086                        MergeProvenanceSvc theMergeProvenanceSvc,
087                        PreviousResourceVersionRestorer theResourceVersionRestorer,
088                        MergeValidationService theMergeValidationService,
089                        IRequestPartitionHelperSvc theRequestPartitionHelperSvc) {
090                myMergeProvenanceSvc = theMergeProvenanceSvc;
091                myResourceVersionRestorer = theResourceVersionRestorer;
092                myFhirContext = theDaoRegistry.getFhirContext();
093                myMergeValidationService = theMergeValidationService;
094                myRequestPartitionHelperSvc = theRequestPartitionHelperSvc;
095                myInputParamNames = new MergeOperationInputParameterNames();
096        }
097
098        public OperationOutcomeWithStatusCode undoMerge(
099                        UndoMergeOperationInputParameters inputParameters, RequestDetails theRequestDetails) {
100
101                OperationOutcomeWithStatusCode undoMergeOutcome = new OperationOutcomeWithStatusCode();
102                IBaseOperationOutcome opOutcome = OperationOutcomeUtil.newInstance(myFhirContext);
103                undoMergeOutcome.setOperationOutcome(opOutcome);
104                try {
105                        return undoMergeInternal(inputParameters, theRequestDetails, undoMergeOutcome);
106                } catch (Exception e) {
107                        ourLog.error("Undo resource merge failed with an exception", e);
108                        if (e instanceof BaseServerResponseException) {
109                                undoMergeOutcome.setHttpStatusCode(((BaseServerResponseException) e).getStatusCode());
110                        } else {
111                                undoMergeOutcome.setHttpStatusCode(STATUS_HTTP_500_INTERNAL_ERROR);
112                        }
113                        addErrorToOperationOutcome(myFhirContext, opOutcome, e.getMessage(), "exception");
114                }
115                return undoMergeOutcome;
116        }
117
118        private OperationOutcomeWithStatusCode undoMergeInternal(
119                        UndoMergeOperationInputParameters inputParameters,
120                        RequestDetails theRequestDetails,
121                        OperationOutcomeWithStatusCode undoMergeOutcome) {
122
123                IBaseOperationOutcome opOutcome = undoMergeOutcome.getOperationOutcome();
124
125                if (!myMergeValidationService.validateCommonMergeOperationParameters(inputParameters, opOutcome)) {
126                        undoMergeOutcome.setHttpStatusCode(STATUS_HTTP_400_BAD_REQUEST);
127                        return undoMergeOutcome;
128                }
129
130                Patient targetPatient =
131                                (Patient) myMergeValidationService.resolveTargetResource(inputParameters, theRequestDetails, opOutcome);
132                IIdType targetId = targetPatient.getIdElement();
133
134                Provenance provenance = null;
135
136                if (inputParameters.getSourceResource() != null) {
137                        // the client provided a source id, use it to find the provenance together with the target id
138                        IIdType sourceId = inputParameters.getSourceResource().getReferenceElement();
139                        provenance =
140                                        myMergeProvenanceSvc.findProvenance(targetId, sourceId, theRequestDetails, OPERATION_UNDO_MERGE);
141                } else {
142                        // the client provided source identifiers, find a provenance using those identifiers and the target id
143                        provenance = myMergeProvenanceSvc.findProvenanceByTargetIdAndSourceIdentifiers(
144                                        targetId, inputParameters.getSourceIdentifiers(), theRequestDetails);
145                }
146
147                if (provenance == null) {
148                        String msg =
149                                        "Unable to find a Provenance created by a $merge operation for the provided source and target resources."
150                                                        + " Ensure that the provided resource references or identifiers were previously used as parameters in a successful $merge operation";
151                        throw new ResourceNotFoundException(Msg.code(2747) + msg);
152                }
153
154                ourLog.info(
155                                "Found Provenance resource with id: {} to be used for $undo-merge operation",
156                                provenance.getIdElement().asStringValue());
157
158                List<Reference> references = provenance.getTarget();
159                if (references.size() > inputParameters.getResourceLimit()) {
160                        String msg = format(
161                                        "Number of references to update (%d) exceeds the limit (%d)",
162                                        references.size(), inputParameters.getResourceLimit());
163                        throw new InvalidRequestException(Msg.code(2748) + msg);
164                }
165
166                RequestPartitionId partitionId = myRequestPartitionHelperSvc.determineReadPartitionForRequest(
167                                theRequestDetails, ReadPartitionIdRequestDetails.forRead(targetPatient.getIdElement()));
168
169                Set<Reference> allowedToUndelete = new HashSet<>();
170                if (wasSourceResourceDeletedByMergeOperation(provenance)) {
171                        // If the source resource was deleted by the merge operation,
172                        // let the version restorer know it can be undeleted.
173                        Reference sourceReference = provenance.getTarget().get(1);
174                        allowedToUndelete.add(sourceReference);
175                }
176
177                List<Reference> referencesToRestore = references;
178                if (wasTargetUpdateANoop(provenance)) {
179                        // skip restoring the target resource if it was not updated by the merge operation.
180                        // This happens when the merge operation deletes the source resource (so the target doesn't have the
181                        // replaces link added) and either the source resource didn't have any identifiers that were copied over to
182                        // the target resource,
183                        // or a resultPatient that didn't change anything in the target was provided.
184                        referencesToRestore = references.subList(1, references.size());
185                }
186
187                myResourceVersionRestorer.restoreToPreviousVersionsInTrx(
188                                referencesToRestore, allowedToUndelete, theRequestDetails, partitionId);
189
190                String msg = format(
191                                "Successfully restored %d resources to their previous versions based on the Provenance resource: %s",
192                                referencesToRestore.size(), provenance.getIdElement().getValue());
193                addInfoToOperationOutcome(myFhirContext, opOutcome, null, msg);
194                undoMergeOutcome.setHttpStatusCode(STATUS_HTTP_200_OK);
195
196                return undoMergeOutcome;
197        }
198
199        private boolean wasSourceResourceDeletedByMergeOperation(Provenance provenance) {
200                if (provenance.hasContained()) {
201                        List<Resource> containedResources = provenance.getContained();
202                        if (!containedResources.isEmpty() && containedResources.get(0) instanceof Parameters parameters) {
203                                if (parameters.hasParameter(myInputParamNames.getDeleteSourceParameterName())) {
204                                        return parameters.getParameterBool(myInputParamNames.getDeleteSourceParameterName());
205                                }
206                                // by default the source resource is not deleted by the merge operation
207                                return false;
208                        }
209                }
210
211                throw new InternalErrorException(Msg.code(2749)
212                                + format(
213                                                "The provenance resource '%s' does not contain the expected contained resource for the input parameters of the merge operation.",
214                                                provenance.getIdElement().asStringValue()));
215        }
216
217        private boolean wasTargetUpdateANoop(Provenance provenance) {
218                List<Resource> containedResources = provenance.getContained();
219
220                // currently the second contained resource is the OperationOutcome of updating the target resource in the
221                // Provenance resource.
222                if (containedResources.size() > 1 && containedResources.get(1) instanceof OperationOutcome operationOutcome) {
223
224                        List<OperationOutcome.OperationOutcomeIssueComponent> issues = operationOutcome.getIssue();
225
226                        return issues.stream()
227                                        .filter(issue -> issue.hasDetails() && issue.getDetails().hasCoding())
228                                        .map(issue -> issue.getDetails().getCoding())
229                                        .flatMap(List::stream)
230                                        .anyMatch(coding -> StorageResponseCodeEnum.SYSTEM.equals(coding.getSystem())
231                                                        && SUCCESSFUL_UPDATE_NO_CHANGE.getCode().equals(coding.getCode()));
232                }
233
234                throw new InternalErrorException(Msg.code(2750)
235                                + String.format(
236                                                "The Provenance resource '%s' does not contain an OperationOutcome of the target resource.",
237                                                provenance.getIdElement().asStringValue()));
238        }
239}