
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}