
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}