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.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}