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.jpa.api.dao.DaoRegistry;
024import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
025import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
026import ca.uhn.fhir.merge.MergeOperationInputParameterNames;
027import ca.uhn.fhir.rest.api.server.IBundleProvider;
028import ca.uhn.fhir.rest.api.server.RequestDetails;
029import ca.uhn.fhir.rest.param.TokenAndListParam;
030import ca.uhn.fhir.rest.param.TokenParam;
031import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException;
032import ca.uhn.fhir.util.CanonicalIdentifier;
033import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
034import org.hl7.fhir.instance.model.api.IBaseReference;
035import org.hl7.fhir.instance.model.api.IBaseResource;
036import org.hl7.fhir.instance.model.api.IIdType;
037import org.hl7.fhir.r4.model.IdType;
038import org.hl7.fhir.r4.model.Identifier;
039import org.hl7.fhir.r4.model.Patient;
040import org.hl7.fhir.r4.model.Reference;
041
042import java.util.ArrayList;
043import java.util.List;
044import java.util.stream.Collectors;
045
046import static ca.uhn.fhir.batch2.jobs.merge.MergeResourceHelper.addErrorToOperationOutcome;
047import static ca.uhn.fhir.rest.api.Constants.STATUS_HTTP_400_BAD_REQUEST;
048import static ca.uhn.fhir.rest.api.Constants.STATUS_HTTP_422_UNPROCESSABLE_ENTITY;
049
050/**
051 * Supporting class that validates input parameters to {@link ResourceMergeService}.
052 */
053public class MergeValidationService {
054        private final FhirContext myFhirContext;
055        private final IFhirResourceDao<Patient> myPatientDao;
056        private final MergeOperationInputParameterNames myInputParamNames;
057
058        public MergeValidationService(FhirContext theFhirContext, DaoRegistry theDaoRegistry) {
059                myFhirContext = theFhirContext;
060                myPatientDao = theDaoRegistry.getResourceDao(Patient.class);
061                myInputParamNames = new MergeOperationInputParameterNames();
062        }
063
064        MergeValidationResult validate(
065                        MergeOperationInputParameters theMergeOperationParameters,
066                        RequestDetails theRequestDetails,
067                        MergeOperationOutcome theMergeOutcome) {
068
069                IBaseOperationOutcome operationOutcome = theMergeOutcome.getOperationOutcome();
070
071                if (!validateCommonMergeOperationParameters(theMergeOperationParameters, operationOutcome)) {
072                        return MergeValidationResult.invalidResult(STATUS_HTTP_400_BAD_REQUEST);
073                }
074
075                // cast to Patient, since we only support merging Patient resources for now
076                Patient sourceResource =
077                                (Patient) resolveSourceResource(theMergeOperationParameters, theRequestDetails, operationOutcome);
078
079                if (sourceResource == null) {
080                        return MergeValidationResult.invalidResult(STATUS_HTTP_422_UNPROCESSABLE_ENTITY);
081                }
082
083                // cast to Patient, since we only support merging Patient resources for now
084                Patient targetResource =
085                                (Patient) resolveTargetResource(theMergeOperationParameters, theRequestDetails, operationOutcome);
086
087                if (targetResource == null) {
088                        return MergeValidationResult.invalidResult(STATUS_HTTP_422_UNPROCESSABLE_ENTITY);
089                }
090
091                if (!validateSourceAndTargetAreSuitableForMerge(sourceResource, targetResource, operationOutcome)) {
092                        return MergeValidationResult.invalidResult(STATUS_HTTP_422_UNPROCESSABLE_ENTITY);
093                }
094
095                if (!validateResultResourceIfExists(
096                                theMergeOperationParameters, targetResource, sourceResource, operationOutcome)) {
097                        return MergeValidationResult.invalidResult(STATUS_HTTP_400_BAD_REQUEST);
098                }
099                return MergeValidationResult.validResult(sourceResource, targetResource);
100        }
101
102        private boolean validateResultResourceIfExists(
103                        MergeOperationInputParameters theMergeOperationParameters,
104                        Patient theResolvedTargetResource,
105                        Patient theResolvedSourceResource,
106                        IBaseOperationOutcome theOperationOutcome) {
107
108                if (theMergeOperationParameters.getResultResource() == null) {
109                        // result resource is not provided, no further validation is needed
110                        return true;
111                }
112
113                boolean retval = true;
114
115                Patient theResultResource = (Patient) theMergeOperationParameters.getResultResource();
116
117                // validate the result resource's  id as same as the target resource
118                if (!theResolvedTargetResource.getIdElement().toVersionless().equals(theResultResource.getIdElement())) {
119                        String msg = String.format(
120                                        "'%s' must have the same versionless id as the actual resolved target resource '%s'. "
121                                                        + "The actual resolved target resource's id is: '%s'",
122                                        myInputParamNames.getResultResourceParameterName(),
123                                        theResultResource.getIdElement(),
124                                        theResolvedTargetResource.getIdElement().toVersionless().getValue());
125                        addErrorToOperationOutcome(myFhirContext, theOperationOutcome, msg, "invalid");
126                        retval = false;
127                }
128
129                // validate the result resource contains the identifiers provided in the target identifiers param
130                if (theMergeOperationParameters.hasAtLeastOneTargetIdentifier()
131                                && !hasAllIdentifiers(theResultResource, theMergeOperationParameters.getTargetIdentifiers())) {
132                        String msg = String.format(
133                                        "'%s' must have all the identifiers provided in %s",
134                                        myInputParamNames.getResultResourceParameterName(),
135                                        myInputParamNames.getTargetIdentifiersParameterName());
136                        addErrorToOperationOutcome(myFhirContext, theOperationOutcome, msg, "invalid");
137                        retval = false;
138                }
139
140                // if the source resource is not being deleted, the result resource must have a replaces link to the source
141                // resource
142                // if the source resource is being deleted, the result resource must not have a replaces link to the source
143                // resource
144                if (!validateResultResourceReplacesLinkToSourceResource(
145                                theResultResource,
146                                theResolvedSourceResource,
147                                myInputParamNames.getResultResourceParameterName(),
148                                theMergeOperationParameters.getDeleteSource(),
149                                theOperationOutcome)) {
150                        retval = false;
151                }
152
153                return retval;
154        }
155
156        private boolean hasAllIdentifiers(Patient theResource, List<CanonicalIdentifier> theIdentifiers) {
157
158                List<Identifier> identifiersInResource = theResource.getIdentifier();
159                for (CanonicalIdentifier identifier : theIdentifiers) {
160                        boolean identifierFound = identifiersInResource.stream()
161                                        .anyMatch(i -> i.getSystem()
162                                                                        .equals(identifier.getSystemElement().getValueAsString())
163                                                        && i.getValue().equals(identifier.getValueElement().getValueAsString()));
164
165                        if (!identifierFound) {
166                                return false;
167                        }
168                }
169                return true;
170        }
171
172        private boolean validateResultResourceReplacesLinkToSourceResource(
173                        Patient theResultResource,
174                        Patient theResolvedSourceResource,
175                        String theResultResourceParameterName,
176                        boolean theDeleteSource,
177                        IBaseOperationOutcome theOperationOutcome) {
178                // the result resource must have the replaces link set to the source resource
179                List<Reference> replacesLinkToSourceResource = getLinksToResource(
180                                theResultResource, Patient.LinkType.REPLACES, theResolvedSourceResource.getIdElement());
181
182                if (theDeleteSource) {
183                        if (!replacesLinkToSourceResource.isEmpty()) {
184                                String msg = String.format(
185                                                "'%s' must not have a 'replaces' link to the source resource "
186                                                                + "when the source resource will be deleted, as the link may prevent deleting the source "
187                                                                + "resource.",
188                                                theResultResourceParameterName);
189                                addErrorToOperationOutcome(myFhirContext, theOperationOutcome, msg, "invalid");
190                                return false;
191                        }
192                } else {
193                        if (replacesLinkToSourceResource.isEmpty()) {
194                                String msg = String.format(
195                                                "'%s' must have a 'replaces' link to the source resource.", theResultResourceParameterName);
196                                addErrorToOperationOutcome(myFhirContext, theOperationOutcome, msg, "invalid");
197                                return false;
198                        }
199
200                        if (replacesLinkToSourceResource.size() > 1) {
201                                String msg = String.format(
202                                                "'%s' has multiple 'replaces' links to the source resource. There should be only one.",
203                                                theResultResourceParameterName);
204                                addErrorToOperationOutcome(myFhirContext, theOperationOutcome, msg, "invalid");
205                                return false;
206                        }
207                }
208                return true;
209        }
210
211        private List<Reference> getLinksToResource(
212                        Patient theResource, Patient.LinkType theLinkType, IIdType theResourceId) {
213                List<Reference> links = getLinksOfTypeWithNonNullReference(theResource, theLinkType);
214                return links.stream()
215                                .filter(r -> theResourceId.toVersionless().getValue().equals(r.getReference()))
216                                .collect(Collectors.toList());
217        }
218
219        private List<Reference> getLinksOfTypeWithNonNullReference(Patient theResource, Patient.LinkType theLinkType) {
220                List<Reference> links = new ArrayList<>();
221                if (theResource.hasLink()) {
222                        for (Patient.PatientLinkComponent link : theResource.getLink()) {
223                                if (theLinkType.equals(link.getType()) && link.hasOther()) {
224                                        links.add(link.getOther());
225                                }
226                        }
227                }
228                return links;
229        }
230
231        private boolean validateSourceAndTargetAreSuitableForMerge(
232                        Patient theSourceResource, Patient theTargetResource, IBaseOperationOutcome outcome) {
233
234                if (theSourceResource.getId().equalsIgnoreCase(theTargetResource.getId())) {
235                        String msg = "Source and target resources are the same resource.";
236                        // What is the right code to use in these cases?
237                        addErrorToOperationOutcome(myFhirContext, outcome, msg, "invalid");
238                        return false;
239                }
240
241                if (theTargetResource.hasActive() && !theTargetResource.getActive()) {
242                        String msg = "Target resource is not active, it must be active to be the target of a merge operation.";
243                        addErrorToOperationOutcome(myFhirContext, outcome, msg, "invalid");
244                        return false;
245                }
246
247                List<Reference> replacedByLinksInTarget =
248                                getLinksOfTypeWithNonNullReference(theTargetResource, Patient.LinkType.REPLACEDBY);
249                if (!replacedByLinksInTarget.isEmpty()) {
250                        String ref = replacedByLinksInTarget.get(0).getReference();
251                        String msg = String.format(
252                                        "Target resource was previously replaced by a resource with reference '%s', it "
253                                                        + "is not a suitable target for merging.",
254                                        ref);
255                        addErrorToOperationOutcome(myFhirContext, outcome, msg, "invalid");
256                        return false;
257                }
258
259                List<Reference> replacedByLinksInSource =
260                                getLinksOfTypeWithNonNullReference(theSourceResource, Patient.LinkType.REPLACEDBY);
261                if (!replacedByLinksInSource.isEmpty()) {
262                        String ref = replacedByLinksInSource.get(0).getReference();
263                        String msg = String.format(
264                                        "Source resource was previously replaced by a resource with reference '%s', it "
265                                                        + "is not a suitable source for merging.",
266                                        ref);
267                        addErrorToOperationOutcome(myFhirContext, outcome, msg, "invalid");
268                        return false;
269                }
270
271                return true;
272        }
273
274        /**
275         * Validates the common input parameters to both merge and undo-merge operations and adds validation errors to the outcome
276         *
277         * @param theCommonInputParameters the operation input parameters
278         * @param theOutcome the outcome to add validation errors to
279         * @return true if the parameters are valid, false otherwise
280         */
281        boolean validateCommonMergeOperationParameters(
282                        MergeOperationsCommonInputParameters theCommonInputParameters, IBaseOperationOutcome theOutcome) {
283                List<String> errorMessages = new ArrayList<>();
284                if (!theCommonInputParameters.hasAtLeastOneSourceIdentifier()
285                                && theCommonInputParameters.getSourceResource() == null) {
286                        String msg = String.format(
287                                        "There are no source resource parameters provided, include either a '%s', or a '%s' parameter.",
288                                        myInputParamNames.getSourceResourceParameterName(),
289                                        myInputParamNames.getSourceIdentifiersParameterName());
290                        errorMessages.add(msg);
291                }
292
293                // Spec has conflicting information about this case
294                if (theCommonInputParameters.hasAtLeastOneSourceIdentifier()
295                                && theCommonInputParameters.getSourceResource() != null) {
296                        String msg = String.format(
297                                        "Source resource must be provided either by '%s' or by '%s', not both.",
298                                        myInputParamNames.getSourceResourceParameterName(),
299                                        myInputParamNames.getSourceIdentifiersParameterName());
300                        errorMessages.add(msg);
301                }
302
303                if (!theCommonInputParameters.hasAtLeastOneTargetIdentifier()
304                                && theCommonInputParameters.getTargetResource() == null) {
305                        String msg = String.format(
306                                        "There are no target resource parameters provided, include either a '%s', or a '%s' parameter.",
307                                        myInputParamNames.getTargetResourceParameterName(),
308                                        myInputParamNames.getTargetIdentifiersParameterName());
309                        errorMessages.add(msg);
310                }
311
312                // Spec has conflicting information about this case
313                if (theCommonInputParameters.hasAtLeastOneTargetIdentifier()
314                                && theCommonInputParameters.getTargetResource() != null) {
315                        String msg = String.format(
316                                        "Target resource must be provided either by '%s' or by '%s', not both.",
317                                        myInputParamNames.getTargetResourceParameterName(),
318                                        myInputParamNames.getTargetIdentifiersParameterName());
319                        errorMessages.add(msg);
320                }
321
322                Reference sourceRef = (Reference) theCommonInputParameters.getSourceResource();
323                if (sourceRef != null && !sourceRef.hasReference()) {
324                        String msg = String.format(
325                                        "Reference specified in '%s' parameter does not have a reference element.",
326                                        myInputParamNames.getSourceResourceParameterName());
327                        errorMessages.add(msg);
328                }
329
330                Reference targetRef = (Reference) theCommonInputParameters.getTargetResource();
331                if (targetRef != null && !targetRef.hasReference()) {
332                        String msg = String.format(
333                                        "Reference specified in '%s' parameter does not have a reference element.",
334                                        myInputParamNames.getTargetResourceParameterName());
335                        errorMessages.add(msg);
336                }
337
338                if (!errorMessages.isEmpty()) {
339                        for (String validationError : errorMessages) {
340                                addErrorToOperationOutcome(myFhirContext, theOutcome, validationError, "required");
341                        }
342                        // there are validation errors
343                        return false;
344                }
345
346                // no validation errors
347                return true;
348        }
349
350        private IBaseResource resolveSourceResource(
351                        MergeOperationsCommonInputParameters theOperationParameters,
352                        RequestDetails theRequestDetails,
353                        IBaseOperationOutcome theOutcome) {
354                return resolveResource(
355                                theOperationParameters.getSourceResource(),
356                                theOperationParameters.getSourceIdentifiers(),
357                                theRequestDetails,
358                                theOutcome,
359                                myInputParamNames.getSourceResourceParameterName(),
360                                myInputParamNames.getSourceIdentifiersParameterName());
361        }
362
363        protected IBaseResource resolveTargetResource(
364                        MergeOperationsCommonInputParameters theOperationParameters,
365                        RequestDetails theRequestDetails,
366                        IBaseOperationOutcome theOutcome) {
367                return resolveResource(
368                                theOperationParameters.getTargetResource(),
369                                theOperationParameters.getTargetIdentifiers(),
370                                theRequestDetails,
371                                theOutcome,
372                                myInputParamNames.getTargetResourceParameterName(),
373                                myInputParamNames.getTargetIdentifiersParameterName());
374        }
375
376        private IBaseResource resolveResource(
377                        IBaseReference theReference,
378                        List<CanonicalIdentifier> theIdentifiers,
379                        RequestDetails theRequestDetails,
380                        IBaseOperationOutcome theOutcome,
381                        String theOperationReferenceParameterName,
382                        String theOperationIdentifiersParameterName) {
383                if (theReference != null) {
384                        return resolveResourceByReference(
385                                        theReference, theRequestDetails, theOutcome, theOperationReferenceParameterName);
386                }
387
388                return resolveResourceByIdentifiers(
389                                theIdentifiers, theRequestDetails, theOutcome, theOperationIdentifiersParameterName);
390        }
391
392        private IBaseResource resolveResourceByIdentifiers(
393                        List<CanonicalIdentifier> theIdentifiers,
394                        RequestDetails theRequestDetails,
395                        IBaseOperationOutcome theOutcome,
396                        String theOperationParameterName) {
397
398                SearchParameterMap searchParameterMap = new SearchParameterMap();
399                TokenAndListParam tokenAndListParam = new TokenAndListParam();
400                for (CanonicalIdentifier identifier : theIdentifiers) {
401                        TokenParam tokenParam = new TokenParam(
402                                        identifier.getSystemElement().getValueAsString(),
403                                        identifier.getValueElement().getValueAsString());
404                        tokenAndListParam.addAnd(tokenParam);
405                }
406                searchParameterMap.add("identifier", tokenAndListParam);
407                searchParameterMap.setCount(2);
408
409                IBundleProvider bundle = myPatientDao.search(searchParameterMap, theRequestDetails);
410                List<IBaseResource> resources = bundle.getAllResources();
411                if (resources.isEmpty()) {
412                        String msg = String.format(
413                                        "No resources found matching the identifier(s) specified in '%s'", theOperationParameterName);
414                        addErrorToOperationOutcome(myFhirContext, theOutcome, msg, "not-found");
415                        return null;
416                }
417                if (resources.size() > 1) {
418                        String msg = String.format(
419                                        "Multiple resources found matching the identifier(s) specified in '%s'", theOperationParameterName);
420                        addErrorToOperationOutcome(myFhirContext, theOutcome, msg, "multiple-matches");
421                        return null;
422                }
423
424                return resources.get(0);
425        }
426
427        private IBaseResource resolveResourceByReference(
428                        IBaseReference theReference,
429                        RequestDetails theRequestDetails,
430                        IBaseOperationOutcome theOutcome,
431                        String theOperationParameterName) {
432                // TODO Emre: why does IBaseReference not have getIdentifier or hasReference methods?
433                // casting it to r4.Reference for now
434                Reference r4ref = (Reference) theReference;
435
436                IIdType theResourceId = new IdType(r4ref.getReferenceElement().getValue());
437                IBaseResource resource;
438                try {
439                        resource = myPatientDao.read(theResourceId.toVersionless(), theRequestDetails);
440                } catch (ResourceNotFoundException e) {
441                        String msg = String.format(
442                                        "Resource not found for the reference specified in '%s' parameter", theOperationParameterName);
443                        addErrorToOperationOutcome(myFhirContext, theOutcome, msg, "not-found");
444                        return null;
445                }
446
447                if (theResourceId.hasVersionIdPart()
448                                && !theResourceId
449                                                .getVersionIdPart()
450                                                .equals(resource.getIdElement().getVersionIdPart())) {
451                        String msg = String.format(
452                                        "The reference in '%s' parameter has a version specified, "
453                                                        + "but it is not the latest version of the resource",
454                                        theOperationParameterName);
455                        addErrorToOperationOutcome(myFhirContext, theOutcome, msg, "conflict");
456                        return null;
457                }
458
459                return resource;
460        }
461}