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}