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