
001/*- 002 * #%L 003 * HAPI FHIR - Core Library 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.util; 021 022import ca.uhn.fhir.context.BaseRuntimeChildDefinition; 023import ca.uhn.fhir.context.BaseRuntimeElementCompositeDefinition; 024import ca.uhn.fhir.context.BaseRuntimeElementDefinition; 025import ca.uhn.fhir.context.ConfigurationException; 026import ca.uhn.fhir.context.FhirContext; 027import ca.uhn.fhir.context.FhirVersionEnum; 028import ca.uhn.fhir.context.RuntimeResourceDefinition; 029import ca.uhn.fhir.context.RuntimeSearchParam; 030import ca.uhn.fhir.i18n.Msg; 031import ca.uhn.fhir.model.api.annotation.Compartment; 032import ca.uhn.fhir.model.api.annotation.SearchParamDefinition; 033import ca.uhn.fhir.parser.DataFormatException; 034import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; 035import jakarta.annotation.Nonnull; 036import jakarta.annotation.Nullable; 037import org.apache.commons.collections4.CollectionUtils; 038import org.apache.commons.lang3.StringUtils; 039import org.apache.commons.lang3.Validate; 040import org.hl7.fhir.instance.model.api.IBase; 041import org.hl7.fhir.instance.model.api.IBaseResource; 042import org.hl7.fhir.instance.model.api.IPrimitiveType; 043 044import java.util.ArrayList; 045import java.util.Arrays; 046import java.util.Collections; 047import java.util.HashMap; 048import java.util.HashSet; 049import java.util.List; 050import java.util.Map; 051import java.util.Optional; 052import java.util.Set; 053import java.util.stream.Collectors; 054 055import static org.apache.commons.lang3.StringUtils.isBlank; 056 057public class SearchParameterUtil { 058 private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(SearchParameterUtil.class); 059 060 /** 061 * This is a list of resources that are in the 062 * <a href="https://build.fhir.org/compartmentdefinition-patient.html">Patient Compartment</a> 063 * but we are omitting anyways for security reasons. 064 * 065 * See <a href="https://github.com/hapifhir/hapi-fhir/issues/7118">this issue</a> for why. 066 */ 067 public static final Map<String, Set<String>> RESOURCE_TYPES_TO_SP_TO_OMIT_FROM_PATIENT_COMPARTMENT = 068 new HashMap<>(); 069 070 static { 071 RESOURCE_TYPES_TO_SP_TO_OMIT_FROM_PATIENT_COMPARTMENT.put("Group", new HashSet<>()); 072 RESOURCE_TYPES_TO_SP_TO_OMIT_FROM_PATIENT_COMPARTMENT.put("List", new HashSet<>()); 073 074 // group 075 RESOURCE_TYPES_TO_SP_TO_OMIT_FROM_PATIENT_COMPARTMENT.get("Group").add("member"); 076 077 // list 078 RESOURCE_TYPES_TO_SP_TO_OMIT_FROM_PATIENT_COMPARTMENT.get("List").add("subject"); 079 RESOURCE_TYPES_TO_SP_TO_OMIT_FROM_PATIENT_COMPARTMENT.get("List").add("source"); 080 RESOURCE_TYPES_TO_SP_TO_OMIT_FROM_PATIENT_COMPARTMENT.get("List").add("patient"); 081 } 082 083 /** 084 * Returns true if the named compartment (Patient, Practitioner, etc) should 085 * include the provided Search Parameter (SP) 086 */ 087 private static boolean shouldCompartmentIncludeSP( 088 String theCompartmentName, SearchParamDefinition theSearchParamDef) { 089 090 // default 091 return Arrays.stream(theSearchParamDef.providesMembershipIn()) 092 .anyMatch(p -> p.name().equals(theCompartmentName)); 093 } 094 095 /** 096 * Retrieves the set of compartments for the provided Resource class and SP definition. 097 * Includes special case handling 098 * 099 * @param theResourceClazz the resource class (ie, Patient.class, Group.class, etc) 100 * @param theSearchParamDefinition the SP definition (from the fields) 101 * @return a set of valid compartments for the provided search parameter. 102 */ 103 public static Set<String> getMembershipCompartmentsForSearchParameter( 104 Class<? extends IBase> theResourceClazz, SearchParamDefinition theSearchParamDefinition) { 105 RestSearchParameterTypeEnum paramType = RestSearchParameterTypeEnum.forCode( 106 theSearchParamDefinition.type().toLowerCase()); 107 if (paramType == null) { 108 throw new ConfigurationException(Msg.code(2786) + "Search param " + theSearchParamDefinition.name() 109 + " has an invalid type: " + theSearchParamDefinition.type()); 110 } 111 112 Set<String> validCompartments = new HashSet<>(); 113 for (Compartment compartment : theSearchParamDefinition.providesMembershipIn()) { 114 if (paramType != RestSearchParameterTypeEnum.REFERENCE) { 115 StringBuilder b = new StringBuilder(); 116 b.append("Search param "); 117 b.append(theSearchParamDefinition.name()); 118 b.append(" on resource type "); 119 b.append(theResourceClazz.getName()); 120 b.append(" provides compartment membership but is not of type 'reference'"); 121 ourLog.warn(b.toString()); 122 continue; 123 } 124 125 if (SearchParameterUtil.shouldCompartmentIncludeSP(compartment.name(), theSearchParamDefinition)) { 126 String compartmentName = getCleansedCompartmentName(compartment.name()); 127 validCompartments.add(compartmentName); 128 } 129 } 130 131 /* 132 * In the base FHIR R4 specification, the Device resource is not a part of the Patient compartment. 133 * However, it is a patient-specific resource that most users expect to be, and several derivative 134 * specifications including g(10) testing expect it to be, and the fact that it is not has led to many 135 * bug reports in HAPI FHIR. As of HAPI FHIR 8.0.0 it is being manually added in response to those 136 * requests. 137 * See https://github.com/hapifhir/hapi-fhir/issues/6536 for more information. 138 */ 139 if (theSearchParamDefinition.name().equals("patient") 140 && theSearchParamDefinition.path().equals("Device.patient")) { 141 validCompartments.add("Patient"); 142 } 143 144 return validCompartments; 145 } 146 147 private static String getCleansedCompartmentName(String theCompartmentName) { 148 // As of 2021-12-28 the R5 structures incorrectly have this prefix 149 if (theCompartmentName.startsWith("Base FHIR compartment definition for ")) { 150 return theCompartmentName.substring("Base FHIR compartment definition for ".length()); 151 } 152 return theCompartmentName; 153 } 154 155 public static List<String> getBaseAsStrings(FhirContext theContext, IBaseResource theResource) { 156 Validate.notNull(theContext, "theContext must not be null"); 157 Validate.notNull(theResource, "theResource must not be null"); 158 RuntimeResourceDefinition def = theContext.getResourceDefinition(theResource); 159 160 BaseRuntimeChildDefinition base = def.getChildByName("base"); 161 List<IBase> baseValues = base.getAccessor().getValues(theResource); 162 List<String> retVal = new ArrayList<>(); 163 for (IBase next : baseValues) { 164 IPrimitiveType<?> nextPrimitive = (IPrimitiveType<?>) next; 165 retVal.add(nextPrimitive.getValueAsString()); 166 } 167 168 return retVal; 169 } 170 171 /** 172 * Given the resource type, fetch its patient-based search parameter name 173 * 1. Attempt to find one called 'patient' 174 * 2. If that fails, find one called 'subject' 175 * 3. If that fails, find one by Patient Compartment. 176 * 3.1 If that returns exactly 1 result then return it 177 * 3.2 If that doesn't return exactly 1 result and is R4, fall to 3.3, otherwise, 3.5 178 * 3.3 If that returns >1 result, throw an error 179 * 3.4 If that returns 1 result, return it 180 * 3.5 Find the search parameters by patient compartment using the R4 FHIR path, and return it if there is 1 result, 181 * otherwise, fall to 3.3 182 */ 183 public static Optional<RuntimeSearchParam> getOnlyPatientSearchParamForResourceType( 184 FhirContext theFhirContext, String theResourceType) { 185 RuntimeSearchParam myPatientSearchParam = null; 186 RuntimeResourceDefinition runtimeResourceDefinition = theFhirContext.getResourceDefinition(theResourceType); 187 myPatientSearchParam = runtimeResourceDefinition.getSearchParam("patient"); 188 if (myPatientSearchParam == null) { 189 myPatientSearchParam = runtimeResourceDefinition.getSearchParam("subject"); 190 if (myPatientSearchParam == null) { 191 final List<RuntimeSearchParam> searchParamsForCurrentVersion = 192 runtimeResourceDefinition.getSearchParamsForCompartmentName("Patient"); 193 final List<RuntimeSearchParam> searchParamsToUse; 194 // We want to handle a narrow code path in which attempting to process SearchParameters for a non-R4 195 // resource would have failed, and instead make another attempt to process them with the R4-equivalent 196 // FHIR path. 197 if (FhirVersionEnum.R4 == theFhirContext.getVersion().getVersion() 198 || searchParamsForCurrentVersion.size() == 1) { 199 searchParamsToUse = searchParamsForCurrentVersion; 200 } else { 201 searchParamsToUse = 202 checkR4PatientCompartmentForMatchingSearchParam(runtimeResourceDefinition, theResourceType); 203 } 204 myPatientSearchParam = 205 validateSearchParamsAndReturnOnlyOne(runtimeResourceDefinition, searchParamsToUse); 206 } 207 } 208 return Optional.of(myPatientSearchParam); 209 } 210 211 @Nonnull 212 private static List<RuntimeSearchParam> checkR4PatientCompartmentForMatchingSearchParam( 213 RuntimeResourceDefinition theRuntimeResourceDefinition, String theResourceType) { 214 final RuntimeSearchParam patientSearchParamForR4 = 215 FhirContext.forR4Cached().getResourceDefinition(theResourceType).getSearchParam("patient"); 216 217 return Optional.ofNullable(patientSearchParamForR4) 218 .map(patientSearchParamForR4NonNull -> 219 theRuntimeResourceDefinition.getSearchParamsForCompartmentName("Patient").stream() 220 .filter(searchParam -> searchParam.getPath() != null) 221 .filter(searchParam -> 222 searchParam.getPath().equals(patientSearchParamForR4NonNull.getPath())) 223 .collect(Collectors.toList())) 224 .orElse(Collections.emptyList()); 225 } 226 227 /** 228 * Given the resource type, fetch all its patient-based search parameter name that's available 229 */ 230 public static Set<String> getPatientSearchParamsForResourceType( 231 FhirContext theFhirContext, String theResourceType) { 232 RuntimeResourceDefinition runtimeResourceDefinition = theFhirContext.getResourceDefinition(theResourceType); 233 234 List<RuntimeSearchParam> searchParams = 235 new ArrayList<>(runtimeResourceDefinition.getSearchParamsForCompartmentName("Patient")); 236 // add patient search parameter for resources that's not in the compartment 237 RuntimeSearchParam myPatientSearchParam = runtimeResourceDefinition.getSearchParam("patient"); 238 if (myPatientSearchParam != null) { 239 searchParams.add(myPatientSearchParam); 240 } 241 RuntimeSearchParam mySubjectSearchParam = runtimeResourceDefinition.getSearchParam("subject"); 242 if (mySubjectSearchParam != null) { 243 searchParams.add(mySubjectSearchParam); 244 } 245 if (CollectionUtils.isEmpty(searchParams)) { 246 String errorMessage = String.format( 247 "Resource type [%s] is not eligible for this type of export, as it contains no Patient compartment, and no `patient` or `subject` search parameter", 248 runtimeResourceDefinition.getId()); 249 throw new IllegalArgumentException(Msg.code(2222) + errorMessage); 250 } 251 // deduplicate list of searchParams and get their names 252 return searchParams.stream().map(RuntimeSearchParam::getName).collect(Collectors.toSet()); 253 } 254 255 /** 256 * Search the resource definition for a compartment named 'patient' and return its related Search Parameter. 257 */ 258 public static RuntimeSearchParam getOnlyPatientCompartmentRuntimeSearchParam( 259 FhirContext theFhirContext, String theResourceType) { 260 RuntimeResourceDefinition resourceDefinition = theFhirContext.getResourceDefinition(theResourceType); 261 return getOnlyPatientCompartmentRuntimeSearchParam(resourceDefinition); 262 } 263 264 public static RuntimeSearchParam getOnlyPatientCompartmentRuntimeSearchParam( 265 RuntimeResourceDefinition runtimeResourceDefinition) { 266 return validateSearchParamsAndReturnOnlyOne( 267 runtimeResourceDefinition, runtimeResourceDefinition.getSearchParamsForCompartmentName("Patient")); 268 } 269 270 public static RuntimeSearchParam getOnlyPatientCompartmentRuntimeSearchParam( 271 RuntimeResourceDefinition runtimeResourceDefinition, List<RuntimeSearchParam> theSearchParams) { 272 return validateSearchParamsAndReturnOnlyOne(runtimeResourceDefinition, theSearchParams); 273 } 274 275 @Nonnull 276 private static RuntimeSearchParam validateSearchParamsAndReturnOnlyOne( 277 RuntimeResourceDefinition theRuntimeResourceDefinition, List<RuntimeSearchParam> theSearchParams) { 278 final RuntimeSearchParam patientSearchParam; 279 if (CollectionUtils.isEmpty(theSearchParams)) { 280 String errorMessage = String.format( 281 "Resource type [%s] for ID [%s] and version: [%s] is not eligible for this type of export, as it contains no Patient compartment, and no `patient` or `subject` search parameter", 282 theRuntimeResourceDefinition.getName(), 283 theRuntimeResourceDefinition.getId(), 284 theRuntimeResourceDefinition.getStructureVersion()); 285 throw new IllegalArgumentException(Msg.code(1774) + errorMessage); 286 } else if (theSearchParams.size() == 1) { 287 patientSearchParam = theSearchParams.get(0); 288 } else { 289 String errorMessage = String.format( 290 "Resource type [%s] for ID [%s] and version: [%s] has more than one Search Param which references a patient compartment. We are unable to disambiguate which patient search parameter we should be searching by.", 291 theRuntimeResourceDefinition.getName(), 292 theRuntimeResourceDefinition.getId(), 293 theRuntimeResourceDefinition.getStructureVersion()); 294 throw new IllegalArgumentException(Msg.code(1775) + errorMessage); 295 } 296 return patientSearchParam; 297 } 298 299 public static List<RuntimeSearchParam> getAllPatientCompartmentRuntimeSearchParamsForResourceType( 300 FhirContext theFhirContext, String theResourceType) { 301 RuntimeResourceDefinition runtimeResourceDefinition = theFhirContext.getResourceDefinition(theResourceType); 302 return getAllPatientCompartmentRuntimeSearchParams(runtimeResourceDefinition); 303 } 304 305 public static List<RuntimeSearchParam> getAllPatientCompartmenRuntimeSearchParams(FhirContext theFhirContext) { 306 return theFhirContext.getResourceTypes().stream() 307 .flatMap(type -> 308 getAllPatientCompartmentRuntimeSearchParamsForResourceType(theFhirContext, type).stream()) 309 .collect(Collectors.toList()); 310 } 311 312 public static Set<String> getAllResourceTypesThatAreInPatientCompartment(FhirContext theFhirContext) { 313 return theFhirContext.getResourceTypes().stream() 314 .filter(type -> CollectionUtils.isNotEmpty( 315 getAllPatientCompartmentRuntimeSearchParamsForResourceType(theFhirContext, type))) 316 .collect(Collectors.toSet()); 317 } 318 319 private static List<RuntimeSearchParam> getAllPatientCompartmentRuntimeSearchParams( 320 RuntimeResourceDefinition theRuntimeResourceDefinition) { 321 List<RuntimeSearchParam> patient = theRuntimeResourceDefinition.getSearchParamsForCompartmentName("Patient"); 322 return patient; 323 } 324 325 /** 326 * Return true if any search parameter in the resource can point at a patient, false otherwise 327 */ 328 public static boolean isResourceTypeInPatientCompartment(FhirContext theFhirContext, String theResourceType) { 329 RuntimeResourceDefinition runtimeResourceDefinition = theFhirContext.getResourceDefinition(theResourceType); 330 return CollectionUtils.isNotEmpty(getAllPatientCompartmentRuntimeSearchParams(runtimeResourceDefinition)); 331 } 332 333 @Nullable 334 public static String getCode(FhirContext theContext, IBaseResource theResource) { 335 return getStringChild(theContext, theResource, "code"); 336 } 337 338 @Nullable 339 public static String getURL(FhirContext theContext, IBaseResource theResource) { 340 return getStringChild(theContext, theResource, "url"); 341 } 342 343 @Nullable 344 public static String getExpression(FhirContext theFhirContext, IBaseResource theResource) { 345 return getStringChild(theFhirContext, theResource, "expression"); 346 } 347 348 private static String getStringChild(FhirContext theFhirContext, IBaseResource theResource, String theChildName) { 349 Validate.notNull(theFhirContext, "theContext must not be null"); 350 Validate.notNull(theResource, "theResource must not be null"); 351 RuntimeResourceDefinition def = theFhirContext.getResourceDefinition(theResource); 352 353 BaseRuntimeChildDefinition base = def.getChildByName(theChildName); 354 return base.getAccessor() 355 .getFirstValueOrNull(theResource) 356 .map(t -> ((IPrimitiveType<?>) t)) 357 .map(t -> t.getValueAsString()) 358 .orElse(null); 359 } 360 361 public static String stripModifier(String theSearchParam) { 362 String retval; 363 int colonIndex = theSearchParam.indexOf(":"); 364 if (colonIndex == -1) { 365 retval = theSearchParam; 366 } else { 367 retval = theSearchParam.substring(0, colonIndex); 368 } 369 return retval; 370 } 371 372 /** 373 * Many SearchParameters combine a series of potential expressions into a single concatenated 374 * expression. For example, in FHIR R5 the "encounter" search parameter has an expression like: 375 * <code>AuditEvent.encounter | CarePlan.encounter | ChargeItem.encounter | ......</code>. 376 * This method takes such a FHIRPath expression and splits it into a series of separate 377 * expressions. To achieve this, we iteratively splits a string on any <code> or </code> or <code>|</code> that 378 * is <b>not</b> contained inside a set of parentheses. e.g. 379 * <p> 380 * "Patient.select(a or b)" --> ["Patient.select(a or b)"] 381 * "Patient.select(a or b) or Patient.select(c or d )" --> ["Patient.select(a or b)", "Patient.select(c or d)"] 382 * "Patient.select(a|b) or Patient.select(c or d )" --> ["Patient.select(a|b)", "Patient.select(c or d)"] 383 * "Patient.select(b) | Patient.select(c)" --> ["Patient.select(b)", "Patient.select(c)"] 384 * 385 * @param thePaths The FHIRPath expression to split 386 * @return The split strings 387 */ 388 public static String[] splitSearchParameterExpressions(String thePaths) { 389 if (!StringUtils.containsAny(thePaths, " or ", " |")) { 390 return new String[] {thePaths}; 391 } 392 List<String> topLevelOrExpressions = splitOutOfParensToken(thePaths, " or "); 393 return topLevelOrExpressions.stream() 394 .flatMap(s -> splitOutOfParensToken(s, " |").stream()) 395 .toArray(String[]::new); 396 } 397 398 private static List<String> splitOutOfParensToken(String thePath, String theToken) { 399 int tokenLength = theToken.length(); 400 int index = thePath.indexOf(theToken); 401 int rightIndex = 0; 402 List<String> retVal = new ArrayList<>(); 403 while (index > -1) { 404 String left = thePath.substring(rightIndex, index); 405 if (allParensHaveBeenClosed(left)) { 406 retVal.add(left.trim()); 407 rightIndex = index + tokenLength; 408 } 409 index = thePath.indexOf(theToken, index + tokenLength); 410 } 411 String pathTrimmed = thePath.substring(rightIndex).trim(); 412 if (!pathTrimmed.isEmpty()) { 413 retVal.add(pathTrimmed); 414 } 415 return retVal; 416 } 417 418 private static boolean allParensHaveBeenClosed(String thePaths) { 419 int open = StringUtils.countMatches(thePaths, "("); 420 int close = StringUtils.countMatches(thePaths, ")"); 421 return open == close; 422 } 423 424 /** 425 * Given a FHIRPath expression which presumably addresses a FHIR reference or 426 * canonical reference element (i.e. a FHIRPath expression used in a "reference" 427 * SearchParameter), tries to determine whether the path could potentially resolve 428 * to a canonical reference. 429 * <p> 430 * Just because a SearchParameter is a Reference SP, doesn't necessarily mean that it 431 * can reference a canonical. So first we try to rule out the SP based on the path it 432 * contains. This matters because a SearchParameter of type Reference can point to 433 * a canonical element (in which case we need to _include any canonical targets). Or it 434 * can point to a Reference element (in which case we only need to _include actual 435 * references by ID). 436 * </p> 437 * <p> 438 * This isn't perfect because there's really no definitive and comprehensive 439 * way of determining the datatype that a SearchParameter or a FHIRPath point to. But 440 * we do our best if the path is simple enough to just manually check the type it 441 * points to, or if it ends in an explicit type declaration. 442 * </p> 443 * <p> 444 * Because it is not possible to deterministically determine the datatype for a 445 * FHIRPath expression in all cases, this method is cautious: it will return 446 * {@literal true} if it isn't sure. 447 * </p> 448 * 449 * @return Returns {@literal true} if the path could return a {@literal canonical} or {@literal CanonicalReference}, or returns {@literal false} if the path could only return a {@literal Reference}. 450 */ 451 public static boolean referencePathCouldPotentiallyReferenceCanonicalElement( 452 FhirContext theContext, String theResourceType, String thePath, boolean theReverse) { 453 454 // If this path explicitly wants a reference and not a canonical, we can ignore it since we're 455 // only looking for canonicals here 456 if (thePath.endsWith(".ofType(Reference)")) { 457 return false; 458 } 459 460 BaseRuntimeElementCompositeDefinition<?> currentDef = theContext.getResourceDefinition(theResourceType); 461 462 String remainingPath = thePath; 463 boolean firstSegment = true; 464 while (remainingPath != null) { 465 466 int dotIdx = remainingPath.indexOf("."); 467 String currentSegment; 468 if (dotIdx == -1) { 469 currentSegment = remainingPath; 470 remainingPath = null; 471 } else { 472 currentSegment = remainingPath.substring(0, dotIdx); 473 remainingPath = remainingPath.substring(dotIdx + 1); 474 } 475 476 if (isBlank(currentSegment)) { 477 return true; 478 } 479 480 if (firstSegment) { 481 firstSegment = false; 482 // The first segment is typically just the resource name, and we assume it is 483 // as long as the first character is upper case. 484 if (Character.isUpperCase(currentSegment.charAt(0))) { 485 486 // If we're in forward-include mode, and the first segment is the resource type, 487 // we can ignore any paths where the resource type isn't actually the one 488 // we're matching on 489 if (!theReverse && !theResourceType.equals(currentSegment)) { 490 return false; 491 } 492 493 // Set the working definition to be the resource type specified in the path 494 try { 495 currentDef = theContext.getResourceDefinition(currentSegment); 496 } catch (DataFormatException e) { 497 ourLog.warn("Search parameter path defines an invalid resource type: {}", currentSegment); 498 } 499 continue; 500 } 501 } 502 503 BaseRuntimeChildDefinition child = currentDef.getChildByName(currentSegment); 504 if (child == null) { 505 return true; 506 } 507 BaseRuntimeElementDefinition<?> def = child.getChildByName(currentSegment); 508 if (def == null) { 509 return true; 510 } 511 if (def.getName().equals("Reference")) { 512 return false; 513 } 514 if (!(def instanceof BaseRuntimeElementCompositeDefinition)) { 515 return true; 516 } 517 518 currentDef = (BaseRuntimeElementCompositeDefinition<?>) def; 519 } 520 521 return true; 522 } 523}