001/*-
002 * #%L
003 * HAPI FHIR Storage api
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.dao.validation;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.context.FhirVersionEnum;
024import ca.uhn.fhir.context.RuntimeSearchParam;
025import ca.uhn.fhir.i18n.Msg;
026import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
027import ca.uhn.fhir.rest.api.Constants;
028import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum;
029import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException;
030import ca.uhn.fhir.rest.server.util.ISearchParamRegistry;
031import ca.uhn.fhir.util.ElementUtil;
032import ca.uhn.fhir.util.HapiExtensions;
033import org.hl7.fhir.instance.model.api.IPrimitiveType;
034import org.hl7.fhir.r5.model.Enumerations;
035import org.hl7.fhir.r5.model.SearchParameter;
036
037import java.util.Collection;
038import java.util.Objects;
039import java.util.Set;
040import java.util.regex.Pattern;
041import java.util.stream.Collectors;
042
043import static ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum.DATE;
044import static ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum.NUMBER;
045import static ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum.QUANTITY;
046import static ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum.REFERENCE;
047import static ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum.STRING;
048import static ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum.TOKEN;
049import static ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum.URI;
050import static org.apache.commons.lang3.StringUtils.isBlank;
051
052public class SearchParameterDaoValidator {
053
054        private static final Pattern REGEX_SP_EXPRESSION_HAS_PATH = Pattern.compile("[( ]*([A-Z][a-zA-Z]+\\.)?[a-z].*");
055
056        private final FhirContext myFhirContext;
057        private final JpaStorageSettings myStorageSettings;
058        private final ISearchParamRegistry mySearchParamRegistry;
059
060        public SearchParameterDaoValidator(
061                        FhirContext theContext,
062                        JpaStorageSettings theStorageSettings,
063                        ISearchParamRegistry theSearchParamRegistry) {
064                myFhirContext = theContext;
065                myStorageSettings = theStorageSettings;
066                mySearchParamRegistry = theSearchParamRegistry;
067        }
068
069        public void validate(SearchParameter searchParameter) {
070                /*
071                 * If overriding built-in SPs is disabled on this server, make sure we aren't
072                 * doing that
073                 */
074                if (myStorageSettings.isDefaultSearchParamsCanBeOverridden() == false) {
075                        for (IPrimitiveType<?> nextBaseType : searchParameter.getBase()) {
076                                String nextBase = nextBaseType.getValueAsString();
077                                RuntimeSearchParam existingSearchParam = mySearchParamRegistry.getActiveSearchParam(
078                                                nextBase, searchParameter.getCode(), ISearchParamRegistry.SearchParamLookupContextEnum.ALL);
079                                if (existingSearchParam != null) {
080                                        boolean isBuiltIn = existingSearchParam.getId() == null;
081                                        isBuiltIn |= existingSearchParam.getUri().startsWith("http://hl7.org/fhir/SearchParameter/");
082                                        if (isBuiltIn) {
083                                                throw new UnprocessableEntityException(
084                                                                Msg.code(1111) + "Can not override built-in search parameter " + nextBase + ":"
085                                                                                + searchParameter.getCode() + " because overriding is disabled on this server");
086                                        }
087                                }
088                        }
089                }
090
091                /*
092                 * Everything below is validating that the SP is actually valid. We'll only do that if the
093                 * SPO is active, so that we don't block people from uploading works-in-progress
094                 */
095                if (searchParameter.getStatus() == null) {
096                        throw new UnprocessableEntityException(Msg.code(1112) + "SearchParameter.status is missing or invalid");
097                }
098                if (!searchParameter.getStatus().name().equals("ACTIVE")) {
099                        return;
100                }
101
102                // Search parameters must have a base
103                if (isCompositeWithoutBase(searchParameter)) {
104                        throw new UnprocessableEntityException(Msg.code(1113) + "SearchParameter.base is missing");
105                }
106
107                // Do we have a valid expression
108                if (isCompositeWithoutExpression(searchParameter)) {
109
110                        // this is ok
111
112                } else if (isBlank(searchParameter.getExpression())) {
113
114                        if (!Constants.PARAM_CONTENT.equals(searchParameter.getCode())
115                                        && !Constants.PARAM_TEXT.equals(searchParameter.getCode())) {
116                                throw new UnprocessableEntityException(Msg.code(1114) + "SearchParameter.expression is missing");
117                        }
118
119                } else {
120
121                        FhirVersionEnum fhirVersion = myFhirContext.getVersion().getVersion();
122                        if (fhirVersion.isOlderThan(FhirVersionEnum.DSTU3)) {
123                                // omitting validation for DSTU2_HL7ORG, DSTU2_1 and DSTU2
124                        } else {
125                                maybeValidateCompositeSpForUniqueIndexing(searchParameter);
126                                maybeValidateSearchParameterExpressionsOnSave(searchParameter);
127                                maybeValidateCompositeWithComponent(searchParameter);
128                        }
129                }
130        }
131
132        private boolean isCompositeSp(SearchParameter theSearchParameter) {
133                return theSearchParameter.getType() != null
134                                && theSearchParameter.getType().equals(Enumerations.SearchParamType.COMPOSITE);
135        }
136
137        private boolean isCompositeWithoutBase(SearchParameter searchParameter) {
138                return ElementUtil.isEmpty(searchParameter.getBase())
139                                && ElementUtil.isEmpty(
140                                                searchParameter.getExtensionsByUrl(HapiExtensions.EXTENSION_SEARCHPARAM_CUSTOM_BASE_RESOURCE))
141                                && !isCompositeSp(searchParameter);
142        }
143
144        private boolean isCompositeWithoutExpression(SearchParameter searchParameter) {
145                return isCompositeSp(searchParameter) && isBlank(searchParameter.getExpression());
146        }
147
148        private boolean isCompositeWithComponent(SearchParameter theSearchParameter) {
149                return isCompositeSp(theSearchParameter) && theSearchParameter.hasComponent();
150        }
151
152        private boolean isCompositeSpForUniqueIndexing(SearchParameter theSearchParameter) {
153                return isCompositeSp(theSearchParameter) && hasAnyExtensionUniqueSetTo(theSearchParameter, true);
154        }
155
156        private void maybeValidateCompositeSpForUniqueIndexing(SearchParameter theSearchParameter) {
157                if (isCompositeSpForUniqueIndexing(theSearchParameter)) {
158                        if (!theSearchParameter.hasComponent()) {
159                                throw new UnprocessableEntityException(
160                                                Msg.code(1115) + "SearchParameter is marked as unique but has no components");
161                        }
162                        for (SearchParameter.SearchParameterComponentComponent next : theSearchParameter.getComponent()) {
163                                if (isBlank(next.getDefinition())) {
164                                        throw new UnprocessableEntityException(
165                                                        Msg.code(1116) + "SearchParameter is marked as unique but is missing component.definition");
166                                }
167                        }
168                }
169        }
170
171        private void maybeValidateSearchParameterExpressionsOnSave(SearchParameter theSearchParameter) {
172                if (myStorageSettings.isValidateSearchParameterExpressionsOnSave()) {
173                        validateExpressionPath(theSearchParameter);
174                        validateExpressionIsParsable(theSearchParameter);
175                }
176        }
177
178        private void validateExpressionPath(SearchParameter theSearchParameter) {
179                String expression = getExpression(theSearchParameter);
180
181                boolean isResourceOfTypeComposite = theSearchParameter.getType() == Enumerations.SearchParamType.COMPOSITE;
182                boolean isResourceOfTypeSpecial = theSearchParameter.getType() == Enumerations.SearchParamType.SPECIAL;
183                boolean expressionHasPath =
184                                REGEX_SP_EXPRESSION_HAS_PATH.matcher(expression).matches();
185
186                boolean isUnique = hasAnyExtensionUniqueSetTo(theSearchParameter, true);
187
188                if (!isUnique && !isResourceOfTypeComposite && !isResourceOfTypeSpecial && !expressionHasPath) {
189                        throw new UnprocessableEntityException(Msg.code(1120) + "SearchParameter.expression value \"" + expression
190                                        + "\" is invalid due to missing/incorrect path");
191                }
192        }
193
194        private void validateExpressionIsParsable(SearchParameter theSearchParameter) {
195                String expression = getExpression(theSearchParameter);
196
197                try {
198                        myFhirContext.newFhirPath().parse(expression);
199                } catch (Exception exception) {
200                        throw new UnprocessableEntityException(
201                                        Msg.code(1121) + "Invalid FHIRPath format for SearchParameter.expression \"" + expression + "\": "
202                                                        + exception.getMessage());
203                }
204        }
205
206        private String getExpression(SearchParameter theSearchParameter) {
207                return theSearchParameter.getExpression().trim();
208        }
209
210        private boolean hasAnyExtensionUniqueSetTo(SearchParameter theSearchParameter, boolean theValue) {
211                String theValueAsString = Boolean.toString(theValue);
212
213                return theSearchParameter.getExtensionsByUrl(HapiExtensions.EXT_SP_UNIQUE).stream()
214                                .anyMatch(t -> theValueAsString.equals(t.getValueAsPrimitive().getValueAsString()));
215        }
216
217        private void maybeValidateCompositeWithComponent(SearchParameter theSearchParameter) {
218                if (isCompositeWithComponent(theSearchParameter)) {
219                        validateCompositeSearchParameterComponents(theSearchParameter);
220                }
221        }
222
223        private void validateCompositeSearchParameterComponents(SearchParameter theSearchParameter) {
224                theSearchParameter.getComponent().stream()
225                                .filter(SearchParameter.SearchParameterComponentComponent::hasDefinition)
226                                .map(SearchParameter.SearchParameterComponentComponent::getDefinition)
227                                .filter(Objects::nonNull)
228                                .map((String url) -> mySearchParamRegistry.getActiveSearchParamByUrl(
229                                                url, ISearchParamRegistry.SearchParamLookupContextEnum.ALL))
230                                .filter(Objects::nonNull)
231                                .forEach(theRuntimeSp -> validateComponentSpTypeAgainstWhiteList(
232                                                theRuntimeSp, getAllowedSearchParameterTypes(theSearchParameter)));
233        }
234
235        private void validateComponentSpTypeAgainstWhiteList(
236                        RuntimeSearchParam theRuntimeSearchParam,
237                        Collection<RestSearchParameterTypeEnum> theAllowedSearchParamTypes) {
238                if (!theAllowedSearchParamTypes.contains(theRuntimeSearchParam.getParamType())) {
239                        throw new UnprocessableEntityException(String.format(
240                                        "%sInvalid component search parameter type: %s in component.definition: %s, supported types: %s",
241                                        Msg.code(2347),
242                                        theRuntimeSearchParam.getParamType().name(),
243                                        theRuntimeSearchParam.getUri(),
244                                        theAllowedSearchParamTypes.stream().map(Enum::name).collect(Collectors.joining(", "))));
245                }
246        }
247
248        /*
249         * Returns allowed Search Parameter Types for a given composite or combo search parameter
250         * This prevents the creation of search parameters that would fail during runtime (during a GET request)
251         * Below you can find references to runtime usage for each parameter type:
252         *
253         * For Composite Search Parameters without HSearch indexing enabled (JPA only):
254         * @see QueryStack#createPredicateCompositePart() and SearchBuilder#createCompositeSort()
255         *
256         * For Composite Search Parameters with HSearch indexing enabled:
257         * @see HSearchCompositeSearchIndexDataImpl#writeIndexEntry()
258         *
259         * For Combo Search Parameters:
260         * @see BaseSearchParamExtractor.extractParameterCombinationsForComboParam()
261         */
262        private Set<RestSearchParameterTypeEnum> getAllowedSearchParameterTypes(SearchParameter theSearchParameter) {
263                // combo unique search parameter
264                if (hasAnyExtensionUniqueSetTo(theSearchParameter, true)) {
265                        return Set.of(STRING, TOKEN, DATE, QUANTITY, URI, NUMBER, REFERENCE);
266                        // combo non-unique search parameter or composite Search Parameter with HSearch indexing
267                } else if (hasAnyExtensionUniqueSetTo(theSearchParameter, false)
268                                || // combo non-unique search parameter
269                                myStorageSettings.isAdvancedHSearchIndexing()) { // composite Search Parameter with HSearch indexing
270                        return Set.of(STRING, TOKEN, DATE, QUANTITY, URI, NUMBER);
271                } else { // composite Search Parameter (JPA only)
272                        return Set.of(STRING, TOKEN, DATE, QUANTITY);
273                }
274        }
275}