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