
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}