
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.searchparam.submit.interceptor; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.context.RuntimeSearchParam; 024import ca.uhn.fhir.i18n.Msg; 025import ca.uhn.fhir.interceptor.api.Hook; 026import ca.uhn.fhir.interceptor.api.Interceptor; 027import ca.uhn.fhir.interceptor.api.Pointcut; 028import ca.uhn.fhir.jpa.api.dao.DaoRegistry; 029import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; 030import ca.uhn.fhir.jpa.api.svc.IIdHelperService; 031import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; 032import ca.uhn.fhir.jpa.searchparam.registry.SearchParameterCanonicalizer; 033import ca.uhn.fhir.rest.api.server.RequestDetails; 034import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId; 035import ca.uhn.fhir.rest.param.TokenAndListParam; 036import ca.uhn.fhir.rest.param.TokenOrListParam; 037import ca.uhn.fhir.rest.param.TokenParam; 038import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; 039import ca.uhn.fhir.util.HapiExtensions; 040import jakarta.annotation.Nullable; 041import org.hl7.fhir.instance.model.api.IBaseExtension; 042import org.hl7.fhir.instance.model.api.IBaseResource; 043import org.slf4j.Logger; 044import org.slf4j.LoggerFactory; 045import org.springframework.beans.factory.annotation.Autowired; 046 047import java.util.HashSet; 048import java.util.List; 049import java.util.Set; 050import java.util.stream.Collectors; 051 052import static org.apache.commons.collections4.CollectionUtils.isNotEmpty; 053import static org.apache.commons.lang3.StringUtils.isBlank; 054import static org.apache.commons.lang3.StringUtils.isNotBlank; 055 056@Interceptor 057public class SearchParamValidatingInterceptor { 058 059 private static final Logger logger = LoggerFactory.getLogger(SearchParamValidatingInterceptor.class); 060 061 public static final String SEARCH_PARAM = "SearchParameter"; 062 public static final String SKIP_VALIDATION = SearchParamValidatingInterceptor.class.getName() + ".SKIP_VALIDATION"; 063 064 private FhirContext myFhirContext; 065 066 private SearchParameterCanonicalizer mySearchParameterCanonicalizer; 067 068 private DaoRegistry myDaoRegistry; 069 070 private IIdHelperService myIdHelperService; 071 072 @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED) 073 public void resourcePreCreate(IBaseResource theResource, RequestDetails theRequestDetails) { 074 validateSearchParamOnCreate(theResource, theRequestDetails); 075 } 076 077 @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED) 078 public void resourcePreUpdate( 079 IBaseResource theOldResource, IBaseResource theNewResource, RequestDetails theRequestDetails) { 080 validateSearchParamOnUpdate(theNewResource, theRequestDetails); 081 } 082 083 public void validateSearchParamOnCreate(IBaseResource theResource, RequestDetails theRequestDetails) { 084 if (isNotSearchParameterResource(theResource)) { 085 return; 086 } 087 088 // avoid a loop when loading our hard-coded core FhirContext SearchParameters 089 boolean isStartup = theRequestDetails != null 090 && Boolean.TRUE == theRequestDetails.getUserData().get(SKIP_VALIDATION); 091 if (isStartup) { 092 return; 093 } 094 095 RuntimeSearchParam runtimeSearchParam = mySearchParameterCanonicalizer.canonicalizeSearchParameter(theResource); 096 if (runtimeSearchParam == null) { 097 return; 098 } 099 100 validateSearchParamOnCreateAndUpdate(runtimeSearchParam); 101 102 SearchParameterMap searchParameterMap = extractSearchParameterMap(runtimeSearchParam); 103 if (searchParameterMap != null) { 104 validateStandardSpOnCreate(theRequestDetails, searchParameterMap); 105 } 106 } 107 108 private void validateSearchParamOnCreateAndUpdate(RuntimeSearchParam theRuntimeSearchParam) { 109 110 // Validate uplifted refchains 111 List<IBaseExtension<?, ?>> refChainExtensions = 112 theRuntimeSearchParam.getExtensions(HapiExtensions.EXTENSION_SEARCHPARAM_UPLIFT_REFCHAIN); 113 for (IBaseExtension<?, ?> nextExtension : refChainExtensions) { 114 List<? extends IBaseExtension> codeExtensions = nextExtension.getExtension().stream() 115 .map(t -> (IBaseExtension<?, ?>) t) 116 .filter(t -> HapiExtensions.EXTENSION_SEARCHPARAM_UPLIFT_REFCHAIN_PARAM_CODE.equals(t.getUrl())) 117 .collect(Collectors.toList()); 118 if (codeExtensions.size() != 1) { 119 throw new UnprocessableEntityException( 120 Msg.code(2283) + "Extension with URL " + HapiExtensions.EXTENSION_SEARCHPARAM_UPLIFT_REFCHAIN 121 + " must have exactly one child extension with URL " 122 + HapiExtensions.EXTENSION_SEARCHPARAM_UPLIFT_REFCHAIN_PARAM_CODE); 123 } 124 if (codeExtensions.get(0).getValue() == null 125 || !"code" 126 .equals(myFhirContext 127 .getElementDefinition( 128 codeExtensions.get(0).getValue().getClass()) 129 .getName())) { 130 throw new UnprocessableEntityException(Msg.code(2284) + "Extension with URL " 131 + HapiExtensions.EXTENSION_SEARCHPARAM_UPLIFT_REFCHAIN_PARAM_CODE 132 + " must have a value of type 'code'"); 133 } 134 } 135 } 136 137 private void validateStandardSpOnCreate(RequestDetails theRequestDetails, SearchParameterMap searchParameterMap) { 138 List<IResourcePersistentId> persistedIdList = getDao().searchForIds(searchParameterMap, theRequestDetails); 139 if (isNotEmpty(persistedIdList)) { 140 throw new UnprocessableEntityException( 141 Msg.code(2196) + "Can't process submitted SearchParameter as it is overlapping an existing one."); 142 } 143 } 144 145 public void validateSearchParamOnUpdate(IBaseResource theResource, RequestDetails theRequestDetails) { 146 if (isNotSearchParameterResource(theResource)) { 147 return; 148 } 149 150 // avoid a loop when loading our hard-coded core FhirContext SearchParameters 151 // skip Search Param validation if been set in the request 152 boolean isStartup = theRequestDetails != null 153 && Boolean.TRUE == theRequestDetails.getUserData().get(SKIP_VALIDATION); 154 if (isStartup) { 155 logger.warn( 156 "Skipping validation of submitted SearchParameter because {} flag is {}", 157 SKIP_VALIDATION, 158 Boolean.TRUE); 159 return; 160 } 161 162 RuntimeSearchParam runtimeSearchParam = mySearchParameterCanonicalizer.canonicalizeSearchParameter(theResource); 163 if (runtimeSearchParam == null) { 164 return; 165 } 166 167 validateSearchParamOnCreateAndUpdate(runtimeSearchParam); 168 169 SearchParameterMap searchParameterMap = extractSearchParameterMap(runtimeSearchParam); 170 if (searchParameterMap != null) { 171 validateStandardSpOnUpdate(theRequestDetails, runtimeSearchParam, searchParameterMap); 172 } 173 } 174 175 private boolean isNewSearchParam(RuntimeSearchParam theSearchParam, Set<String> theExistingIds) { 176 return theExistingIds.stream().noneMatch(resId -> resId.substring(resId.indexOf("/") + 1) 177 .equals(theSearchParam.getId().getIdPart())); 178 } 179 180 private void validateStandardSpOnUpdate( 181 RequestDetails theRequestDetails, 182 RuntimeSearchParam runtimeSearchParam, 183 SearchParameterMap searchParameterMap) { 184 List<IResourcePersistentId> pidList = getDao().searchForIds(searchParameterMap, theRequestDetails); 185 if (isNotEmpty(pidList)) { 186 Set<String> resolvedResourceIds = myIdHelperService.translatePidsToFhirResourceIds(new HashSet<>(pidList)); 187 if (isNewSearchParam(runtimeSearchParam, resolvedResourceIds)) { 188 throwDuplicateError(); 189 } 190 } 191 } 192 193 private void throwDuplicateError() { 194 throw new UnprocessableEntityException( 195 Msg.code(2125) + "Can't process submitted SearchParameter as it is overlapping an existing one."); 196 } 197 198 private boolean isNotSearchParameterResource(IBaseResource theResource) { 199 return !SEARCH_PARAM.equalsIgnoreCase(myFhirContext.getResourceType(theResource)); 200 } 201 202 @Nullable 203 private SearchParameterMap extractSearchParameterMap(RuntimeSearchParam theRuntimeSearchParam) { 204 SearchParameterMap retVal = new SearchParameterMap(); 205 206 String code = theRuntimeSearchParam.getName(); 207 List<String> theBases = List.copyOf(theRuntimeSearchParam.getBase()); 208 if (isBlank(code) || theBases.isEmpty()) { 209 return null; 210 } 211 212 TokenAndListParam codeParam = new TokenAndListParam().addAnd(new TokenParam(code)); 213 TokenAndListParam basesParam = toTokenAndList(theBases); 214 215 retVal.add("code", codeParam); 216 retVal.add("base", basesParam); 217 218 return retVal; 219 } 220 221 @Autowired 222 public void setFhirContext(FhirContext theFhirContext) { 223 myFhirContext = theFhirContext; 224 } 225 226 @Autowired 227 public void setSearchParameterCanonicalizer(SearchParameterCanonicalizer theSearchParameterCanonicalizer) { 228 mySearchParameterCanonicalizer = theSearchParameterCanonicalizer; 229 } 230 231 @Autowired 232 public void setDaoRegistry(DaoRegistry theDaoRegistry) { 233 myDaoRegistry = theDaoRegistry; 234 } 235 236 @Autowired 237 public void setIIDHelperService(IIdHelperService theIdHelperService) { 238 myIdHelperService = theIdHelperService; 239 } 240 241 private IFhirResourceDao getDao() { 242 return myDaoRegistry.getResourceDao(SEARCH_PARAM); 243 } 244 245 private TokenAndListParam toTokenAndList(List<String> theBases) { 246 TokenAndListParam retVal = new TokenAndListParam(); 247 248 if (theBases != null) { 249 250 TokenOrListParam tokenOrListParam = new TokenOrListParam(); 251 retVal.addAnd(tokenOrListParam); 252 253 for (String next : theBases) { 254 if (isNotBlank(next)) { 255 tokenOrListParam.addOr(new TokenParam(next)); 256 } 257 } 258 } 259 260 if (retVal.getValuesAsQueryTokens().isEmpty()) { 261 return null; 262 } 263 264 return retVal; 265 } 266}