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}