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}