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}