
001/*- 002 * #%L 003 * HAPI FHIR Storage api 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.replacereferences; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.jpa.api.dao.DaoRegistry; 024import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; 025import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; 026import ca.uhn.fhir.model.api.IProvenanceAgent; 027import ca.uhn.fhir.model.api.StorageResponseCodeEnum; 028import ca.uhn.fhir.model.api.TemporalPrecisionEnum; 029import ca.uhn.fhir.model.primitive.IdDt; 030import ca.uhn.fhir.rest.api.SortOrderEnum; 031import ca.uhn.fhir.rest.api.SortSpec; 032import ca.uhn.fhir.rest.api.server.IBundleProvider; 033import ca.uhn.fhir.rest.api.server.RequestDetails; 034import ca.uhn.fhir.rest.param.ReferenceParam; 035import ca.uhn.fhir.util.FhirTerser; 036import jakarta.annotation.Nullable; 037import org.hl7.fhir.instance.model.api.IBaseReference; 038import org.hl7.fhir.instance.model.api.IBaseResource; 039import org.hl7.fhir.instance.model.api.IIdType; 040import org.hl7.fhir.r4.model.Bundle; 041import org.hl7.fhir.r4.model.CodeableConcept; 042import org.hl7.fhir.r4.model.OperationOutcome; 043import org.hl7.fhir.r4.model.Period; 044import org.hl7.fhir.r4.model.Provenance; 045import org.hl7.fhir.r4.model.Reference; 046import org.hl7.fhir.r4.model.Resource; 047import org.slf4j.Logger; 048import org.slf4j.LoggerFactory; 049 050import java.util.ArrayList; 051import java.util.Date; 052import java.util.List; 053 054import static ca.uhn.fhir.model.api.StorageResponseCodeEnum.SUCCESSFUL_PATCH_NO_CHANGE; 055 056/** 057 * This service is used to create a Provenance resource for the $replace-references operation 058 * and also used as a base class for {@link ca.uhn.fhir.merge.MergeProvenanceSvc} used in $merge operations. 059 * The two operations use different activity codes. 060 */ 061public class ReplaceReferencesProvenanceSvc { 062 063 private static final Logger ourLog = LoggerFactory.getLogger(ReplaceReferencesProvenanceSvc.class); 064 private static final String ACT_REASON_CODE_SYSTEM = "http://terminology.hl7.org/CodeSystem/v3-ActReason"; 065 private static final String ACT_REASON_PATIENT_ADMINISTRATION_CODE = "PATADMIN"; 066 protected static final String ACTIVITY_CODE_SYSTEM = "http://terminology.hl7.org/CodeSystem/iso-21089-lifecycle"; 067 private static final String ACTIVITY_CODE_LINK = "link"; 068 private final IFhirResourceDao<IBaseResource> myProvenanceDao; 069 private final FhirContext myFhirContext; 070 071 public ReplaceReferencesProvenanceSvc(DaoRegistry theDaoRegistry) { 072 myProvenanceDao = theDaoRegistry.getResourceDao("Provenance"); 073 myFhirContext = theDaoRegistry.getFhirContext(); 074 } 075 076 protected CodeableConcept getActivityCodeableConcept() { 077 CodeableConcept retVal = new CodeableConcept(); 078 retVal.addCoding().setSystem(ACTIVITY_CODE_SYSTEM).setCode(ACTIVITY_CODE_LINK); 079 return retVal; 080 } 081 082 protected Provenance createProvenanceObject( 083 Reference theTargetReference, 084 @Nullable Reference theSourceReference, 085 List<Reference> theUpdatedReferencingResources, 086 Date theStartTime, 087 List<IProvenanceAgent> theProvenanceAgents, 088 List<IBaseResource> theContainedResources) { 089 Provenance provenance = new Provenance(); 090 091 Date now = new Date(); 092 provenance.setOccurred(new Period() 093 .setStart(theStartTime, TemporalPrecisionEnum.MILLI) 094 .setEnd(now, TemporalPrecisionEnum.MILLI)); 095 provenance.setRecorded(now); 096 097 addAgents(theProvenanceAgents, provenance); 098 099 CodeableConcept activityCodeableConcept = getActivityCodeableConcept(); 100 if (activityCodeableConcept != null) { 101 provenance.setActivity(activityCodeableConcept); 102 } 103 CodeableConcept activityReasonCodeableConcept = new CodeableConcept(); 104 activityReasonCodeableConcept 105 .addCoding() 106 .setSystem(ACT_REASON_CODE_SYSTEM) 107 .setCode(ACT_REASON_PATIENT_ADMINISTRATION_CODE); 108 109 provenance.addReason(activityReasonCodeableConcept); 110 111 provenance.addTarget(theTargetReference); 112 provenance.addTarget(theSourceReference); 113 114 theUpdatedReferencingResources.forEach(provenance::addTarget); 115 theContainedResources.forEach(c -> provenance.addContained((Resource) c)); 116 return provenance; 117 } 118 119 /** 120 * Creates a Provenance resource for the $replace-references and $merge operations. 121 * 122 * @param theTargetId the versioned id of the target resource of the operation. 123 * @param theSourceId the versioned id of the source resource of the operation. 124 * @param thePatchResultBundles the list of patch result bundles that contain the updated resources. 125 * @param theStartTime the start time of the operation. 126 * @param theRequestDetails the request details 127 * @param theProvenanceAgents the list of agents to be included in the Provenance resource. 128 */ 129 public void createProvenance( 130 IIdType theTargetId, 131 IIdType theSourceId, 132 List<Bundle> thePatchResultBundles, 133 Date theStartTime, 134 RequestDetails theRequestDetails, 135 List<IProvenanceAgent> theProvenanceAgents, 136 List<IBaseResource> theContainedResources) { 137 createProvenance( 138 theTargetId, 139 theSourceId, 140 thePatchResultBundles, 141 theStartTime, 142 theRequestDetails, 143 theProvenanceAgents, 144 theContainedResources, 145 // if no referencing resource were updated, we don't need to create a Provenance resource, because 146 // replace-references doesn't update the src and target resources, unlike the $merge operation 147 false); 148 } 149 150 protected void createProvenance( 151 IIdType theTargetId, 152 IIdType theSourceId, 153 List<Bundle> thePatchResultBundles, 154 Date theStartTime, 155 RequestDetails theRequestDetails, 156 List<IProvenanceAgent> theProvenanceAgents, 157 List<IBaseResource> theContainedResources, 158 boolean theCreateEvenWhenNoReferencesWereUpdated) { 159 Reference targetReference = new Reference(theTargetId); 160 Reference sourceReference = new Reference(theSourceId); 161 List<Reference> patchedReferences = extractUpdatedResourceReferences(thePatchResultBundles); 162 if (!patchedReferences.isEmpty() || theCreateEvenWhenNoReferencesWereUpdated) { 163 Provenance provenance = createProvenanceObject( 164 targetReference, 165 sourceReference, 166 patchedReferences, 167 theStartTime, 168 theProvenanceAgents, 169 theContainedResources); 170 myProvenanceDao.create(provenance, theRequestDetails); 171 } 172 } 173 174 /** 175 * Finds a Provenance resource that contain the given target and source references, 176 * and with the activity code this class generates Provenance resource with. If multiple Provenance resources 177 * found, returns the most recent one based on the 'recorded' field. 178 * @param theTargetId the target resource id 179 * @param theSourceId the source resource id 180 * @param theRequestDetails the request details 181 * @param theOperationName the name of operation trying to find the provenance resource, used for logging. 182 * @return the found Provenance resource, or null if not found. 183 */ 184 @Nullable 185 public Provenance findProvenance( 186 IIdType theTargetId, IIdType theSourceId, RequestDetails theRequestDetails, String theOperationName) { 187 188 List<Provenance> provenances = 189 getProvenancesOfTargetsFilteredByActivity(List.of(theTargetId, theSourceId), theRequestDetails); 190 191 if (provenances.isEmpty()) { 192 return null; 193 } 194 195 if (provenances.size() > 1) { 196 // If there are multiple Provenance resources, we return the most recent one, but log a warning 197 ourLog.warn( 198 "There are multiple Provenance resources with the given source {} and target {} suitable for {} operation, " 199 + "will use the most recent one. Provenance count: {}", 200 theSourceId, 201 theTargetId, 202 theOperationName, 203 provenances.size()); 204 } 205 206 Provenance provenance = provenances.get(0); 207 if (isTargetAndSourceInCorrectOrder(provenance, theTargetId, theSourceId)) { 208 return provenance; 209 } else { 210 return null; 211 } 212 } 213 214 protected List<Provenance> getProvenancesOfTargetsFilteredByActivity( 215 List<IIdType> theTargetIds, RequestDetails theRequestDetails) { 216 SearchParameterMap map = new SearchParameterMap(); 217 218 theTargetIds.forEach(tId -> map.add("target", new ReferenceParam(tId))); 219 220 // Add sort by recorded field, in case there are multiple Provenance resources for the same source and target, 221 // we want the most recent one. 222 map.setSort(new SortSpec("recorded", SortOrderEnum.DESC)); 223 224 IBundleProvider searchBundle = myProvenanceDao.search(map, theRequestDetails); 225 // 'activity' is not available as a search parameter in r4, was added in r5, 226 // so we need to filter the results manually. 227 return filterByActivity(searchBundle.getAllResources()); 228 } 229 230 private List<Provenance> filterByActivity(List<IBaseResource> theResources) { 231 List<Provenance> filteredProvenances = new ArrayList<>(); 232 for (IBaseResource resource : theResources) { 233 Provenance provenance = (Provenance) resource; 234 if (provenance.hasActivity() && provenance.getActivity().equalsDeep(getActivityCodeableConcept())) { 235 filteredProvenances.add(provenance); 236 } 237 } 238 return filteredProvenances; 239 } 240 241 /** 242 * Checks if the first 'Provenance.target' reference matches theTargetId and the second matches theSourceId. 243 * The $hapi.fhir.replace-references and $merge operations create their Provenance resource with targets in that order. 244 * @param provenance The Provenance resource to check. 245 * @param theTargetId The expected target IIdType for the first reference. 246 * @param theSourceId The expected source IIdType for the second reference. 247 * @return true if both match, false otherwise. 248 */ 249 public boolean isTargetAndSourceInCorrectOrder(Provenance provenance, IIdType theTargetId, IIdType theSourceId) { 250 if (provenance.getTarget().size() < 2) { 251 ourLog.error( 252 "Provenance resource {} does not have enough targets. Expected at least 2, found {}.", 253 provenance.getIdElement().getValue(), 254 provenance.getTarget().size()); 255 return false; 256 } 257 Reference firstTargetRefInProv = provenance.getTarget().get(0); 258 Reference secondTargetRefInProv = provenance.getTarget().get(1); 259 260 boolean firstMatches = isEqualVersionlessId(theTargetId, firstTargetRefInProv); 261 boolean secondMatches = isEqualVersionlessId(theSourceId, secondTargetRefInProv); 262 263 boolean result = firstMatches && secondMatches; 264 265 if (!result) { 266 ourLog.error( 267 "Provenance resource {} doesn't have the expected target and source references or they are in the wrong order. " 268 + "Expected target: {}, source: {}, but found target: {}, source: {}", 269 provenance.getIdElement().getValue(), 270 theTargetId.getValue(), 271 theSourceId.getValue(), 272 firstTargetRefInProv.getReference(), 273 secondTargetRefInProv.getReference()); 274 } 275 276 return result; 277 } 278 279 private boolean isEqualVersionlessId(IIdType theId, Reference theReference) { 280 if (!theReference.hasReference()) { 281 return false; 282 } 283 return theId.toUnqualifiedVersionless() 284 .getValue() 285 .equals(new IdDt(theReference.getReference()) 286 .toUnqualifiedVersionless() 287 .getValue()); 288 } 289 290 protected List<Reference> extractUpdatedResourceReferences(List<Bundle> thePatchBundles) { 291 List<Reference> patchedResourceReferences = new ArrayList<>(); 292 thePatchBundles.forEach(outputBundle -> { 293 outputBundle.getEntry().forEach(entry -> { 294 if (entry.getResponse() != null && entry.getResponse().hasLocation()) { 295 if (isNoopPatch(entry.getResponse())) { 296 // in the unlikely event that the patch resulted in a no-op change, 297 // don't add the reference to the Provenance since it wasn't really updated by the transaction. 298 ourLog.warn( 299 "Not adding reference {} to Provenance, because the patch resulted in a no-op change", 300 entry.getResponse().getLocation()); 301 return; 302 } 303 Reference reference = new Reference(entry.getResponse().getLocation()); 304 patchedResourceReferences.add(reference); 305 } 306 }); 307 }); 308 return patchedResourceReferences; 309 } 310 311 private boolean isNoopPatch(Bundle.BundleEntryResponseComponent theResponse) { 312 if (!theResponse.hasOutcome()) { 313 return false; 314 } 315 316 OperationOutcome outcome = (OperationOutcome) theResponse.getOutcome(); 317 318 if (!outcome.hasIssue()) { 319 return false; 320 } 321 322 List<OperationOutcome.OperationOutcomeIssueComponent> issues = outcome.getIssue(); 323 324 return issues.stream() 325 .filter(issue -> issue.hasDetails() && issue.getDetails().hasCoding()) 326 .map(issue -> issue.getDetails().getCoding()) 327 .flatMap(List::stream) 328 .anyMatch(coding -> StorageResponseCodeEnum.SYSTEM.equals(coding.getSystem()) 329 && SUCCESSFUL_PATCH_NO_CHANGE.getCode().equals(coding.getCode())); 330 } 331 332 private Provenance.ProvenanceAgentComponent createR4ProvenanceAgent(IProvenanceAgent theProvenanceAgent) { 333 Provenance.ProvenanceAgentComponent agent = new Provenance.ProvenanceAgentComponent(); 334 Reference whoRef = convertToR4Reference(theProvenanceAgent.getWho()); 335 agent.setWho(whoRef); 336 Reference onBehalfOfRef = convertToR4Reference(theProvenanceAgent.getOnBehalfOf()); 337 agent.setOnBehalfOf(onBehalfOfRef); 338 return agent; 339 } 340 341 private void addAgents(List<IProvenanceAgent> theProvenanceAgents, Provenance theProvenance) { 342 if (theProvenanceAgents != null) { 343 for (IProvenanceAgent agent : theProvenanceAgents) { 344 Provenance.ProvenanceAgentComponent r4Agent = createR4ProvenanceAgent(agent); 345 theProvenance.addAgent(r4Agent); 346 } 347 } 348 } 349 350 private Reference convertToR4Reference(IBaseReference sourceRef) { 351 if (sourceRef == null) { 352 return null; 353 } 354 FhirTerser terser = myFhirContext.newTerser(); 355 Reference targetRef = new Reference(); 356 terser.cloneInto(sourceRef, targetRef, false); 357 return targetRef; 358 } 359}