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