001/*-
002 * #%L
003 * HAPI FHIR JPA - Search Parameters
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;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.context.RuntimeResourceDefinition;
024import ca.uhn.fhir.context.RuntimeSearchParam;
025import ca.uhn.fhir.i18n.Msg;
026import ca.uhn.fhir.interceptor.model.RequestPartitionId;
027import ca.uhn.fhir.jpa.model.util.JpaConstants;
028import ca.uhn.fhir.jpa.searchparam.util.JpaParamUtil;
029import ca.uhn.fhir.model.api.IQueryParameterAnd;
030import ca.uhn.fhir.model.api.IQueryParameterType;
031import ca.uhn.fhir.model.api.Include;
032import ca.uhn.fhir.rest.api.Constants;
033import ca.uhn.fhir.rest.api.QualifiedParamList;
034import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum;
035import ca.uhn.fhir.rest.api.SearchIncludeDeletedEnum;
036import ca.uhn.fhir.rest.api.SearchTotalModeEnum;
037import ca.uhn.fhir.rest.param.DateRangeParam;
038import ca.uhn.fhir.rest.param.ParameterUtil;
039import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
040import ca.uhn.fhir.rest.server.util.ISearchParamRegistry;
041import ca.uhn.fhir.rest.server.util.MatchUrlUtil;
042import ca.uhn.fhir.util.ReflectionUtil;
043import ca.uhn.fhir.util.UrlUtil;
044import com.google.common.collect.ArrayListMultimap;
045import org.apache.http.NameValuePair;
046import org.springframework.beans.factory.annotation.Autowired;
047
048import java.util.HashSet;
049import java.util.List;
050import java.util.Set;
051
052import static ca.uhn.fhir.jpa.searchparam.ResourceMetaParams.STRICT_RESOURCE_META_PARAMS;
053import static org.apache.commons.lang3.StringUtils.isBlank;
054import static org.apache.commons.lang3.StringUtils.isNotBlank;
055
056public class MatchUrlService {
057
058        public static final Set<String> COMPATIBLE_PARAMS_NO_RES_TYPE =
059                        Set.of(Constants.PARAM_INCLUDE_DELETED, Constants.PARAM_LASTUPDATED);
060        public static final Set<String> COMPATIBLE_PARAMS_GIVEN_RES_TYPE =
061                        Set.of(Constants.PARAM_INCLUDE_DELETED, Constants.PARAM_LASTUPDATED, Constants.PARAM_ID);
062
063        @Autowired
064        private FhirContext myFhirContext;
065
066        @Autowired
067        private ISearchParamRegistry mySearchParamRegistry;
068
069        public MatchUrlService() {
070                super();
071        }
072
073        public MatchUrlService(FhirContext theFhirContext, ISearchParamRegistry theSearchParamRegistry) {
074                myFhirContext = theFhirContext;
075                mySearchParamRegistry = theSearchParamRegistry;
076        }
077
078        /**
079         * Parses a match URL of the form "[resourceType]?[params]"
080         *
081         * @since 6.8.0
082         */
083        public ResourceTypeAndSearchParameterMap parseAndTranslateMatchUrl(String theMatchUrl, Flag... theFlags) {
084                RuntimeResourceDefinition rd = UrlUtil.parseUrlResourceType(myFhirContext, theMatchUrl);
085                SearchParameterMap params = translateMatchUrl(theMatchUrl, rd, theFlags);
086                return new ResourceTypeAndSearchParameterMap(rd, params);
087        }
088
089        /**
090         * Parses a match URL of the form "[resourceType]?[params]" or "?[params]"
091         */
092        public SearchParameterMap translateMatchUrl(
093                        String theMatchUrl, RuntimeResourceDefinition theResourceDefinition, Flag... theFlags) {
094                SearchParameterMap paramMap = new SearchParameterMap();
095                List<NameValuePair> parameters = MatchUrlUtil.translateMatchUrl(theMatchUrl);
096
097                ArrayListMultimap<String, QualifiedParamList> nameToParamLists = ArrayListMultimap.create();
098                for (NameValuePair next : parameters) {
099                        if (isBlank(next.getValue())) {
100                                continue;
101                        }
102
103                        String paramName = next.getName();
104                        String qualifier = null;
105                        for (int i = 0; i < paramName.length(); i++) {
106                                switch (paramName.charAt(i)) {
107                                        case '.':
108                                        case ':':
109                                                qualifier = paramName.substring(i);
110                                                paramName = paramName.substring(0, i);
111                                                i = Integer.MAX_VALUE - 1;
112                                                break;
113                                }
114                        }
115
116                        QualifiedParamList paramList =
117                                        QualifiedParamList.splitQueryStringByCommasIgnoreEscape(qualifier, next.getValue());
118                        nameToParamLists.put(paramName, paramList);
119                }
120
121                boolean hasNoResourceType = hasNoResourceTypeInUrl(theMatchUrl, theResourceDefinition);
122
123                if (hasNoResourceType && !isSupportedQueryForNoProvidedResourceType(nameToParamLists.keySet())) {
124                        // Of all the general FHIR search parameters: https://hl7.org/fhir/R4/search.html#table
125                        // We can _only_ process the parameters on resource.meta fields for server requests
126                        // The following require a provided resource type because:
127                        // - Both _text and _content requires the FullTextSearchSvc and can only be performed on DomainResources
128                        // - _id since it is part of the unique constraint in the DB (see ResourceTableDao)
129                        // - Both _list and _has allows complex chaining with other resource-specific search params
130                        String errorMsg = myFhirContext.getLocalizer().getMessage(MatchUrlService.class, "noResourceType");
131                        throw new IllegalArgumentException(Msg.code(2742) + errorMsg);
132                }
133
134                for (String nextParamName : nameToParamLists.keySet()) {
135                        List<QualifiedParamList> paramList = nameToParamLists.get(nextParamName);
136
137                        if (theFlags != null) {
138                                for (Flag next : theFlags) {
139                                        next.process(nextParamName, paramList, paramMap);
140                                }
141                        }
142
143                        if (Constants.PARAM_INCLUDE_DELETED.equals(nextParamName)) {
144                                validateParamsAreCompatibleForDeleteOrThrow(nameToParamLists.keySet(), hasNoResourceType);
145                                paramMap.setSearchIncludeDeletedMode(
146                                                SearchIncludeDeletedEnum.fromCode(paramList.get(0).get(0)));
147                        } else if (Constants.PARAM_LASTUPDATED.equals(nextParamName)) {
148                                if (!paramList.isEmpty()) {
149                                        if (paramList.size() > 2) {
150                                                throw new InvalidRequestException(Msg.code(484) + "Failed to parse match URL[" + theMatchUrl
151                                                                + "] - Can not have more than 2 " + Constants.PARAM_LASTUPDATED
152                                                                + " parameter repetitions");
153                                        } else {
154                                                DateRangeParam p1 = new DateRangeParam();
155                                                p1.setValuesAsQueryTokens(myFhirContext, nextParamName, paramList);
156                                                paramMap.setLastUpdated(p1);
157                                        }
158                                }
159                        } else if (Constants.PARAM_HAS.equals(nextParamName)) {
160                                IQueryParameterAnd<?> param = JpaParamUtil.parseQueryParams(
161                                                myFhirContext, RestSearchParameterTypeEnum.HAS, nextParamName, paramList);
162                                paramMap.add(nextParamName, param);
163                        } else if (Constants.PARAM_COUNT.equals(nextParamName)) {
164                                if (!paramList.isEmpty() && !paramList.get(0).isEmpty()) {
165                                        String intString = paramList.get(0).get(0);
166                                        try {
167                                                paramMap.setCount(Integer.parseInt(intString));
168                                        } catch (NumberFormatException e) {
169                                                throw new InvalidRequestException(
170                                                                Msg.code(485) + "Invalid " + Constants.PARAM_COUNT + " value: " + intString);
171                                        }
172                                }
173                        } else if (Constants.PARAM_SEARCH_TOTAL_MODE.equals(nextParamName)) {
174                                if (!paramList.isEmpty() && !paramList.get(0).isEmpty()) {
175                                        String totalModeEnumStr = paramList.get(0).get(0);
176                                        SearchTotalModeEnum searchTotalMode = SearchTotalModeEnum.fromCode(totalModeEnumStr);
177                                        if (searchTotalMode == null) {
178                                                // We had an oops here supporting the UPPER CASE enum instead of the FHIR code for _total.
179                                                // Keep supporting it in case someone is using it.
180                                                try {
181                                                        searchTotalMode = SearchTotalModeEnum.valueOf(totalModeEnumStr);
182                                                } catch (IllegalArgumentException e) {
183                                                        throw new InvalidRequestException(Msg.code(2078) + "Invalid "
184                                                                        + Constants.PARAM_SEARCH_TOTAL_MODE + " value: " + totalModeEnumStr);
185                                                }
186                                        }
187                                        paramMap.setSearchTotalMode(searchTotalMode);
188                                }
189                        } else if (Constants.PARAM_OFFSET.equals(nextParamName)) {
190                                if (!paramList.isEmpty() && !paramList.get(0).isEmpty()) {
191                                        String intString = paramList.get(0).get(0);
192                                        try {
193                                                paramMap.setOffset(Integer.parseInt(intString));
194                                        } catch (NumberFormatException e) {
195                                                throw new InvalidRequestException(
196                                                                Msg.code(486) + "Invalid " + Constants.PARAM_OFFSET + " value: " + intString);
197                                        }
198                                }
199                        } else if (ResourceMetaParams.RESOURCE_META_PARAMS.containsKey(nextParamName)) {
200                                if (isNotBlank(paramList.get(0).getQualifier())
201                                                && paramList.get(0).getQualifier().startsWith(".")) {
202                                        throw new InvalidRequestException(Msg.code(487) + "Invalid parameter chain: " + nextParamName
203                                                        + paramList.get(0).getQualifier());
204                                }
205                                IQueryParameterAnd<?> type = newInstanceAnd(nextParamName);
206                                type.setValuesAsQueryTokens(myFhirContext, nextParamName, (paramList));
207                                paramMap.add(nextParamName, type);
208                        } else if (Constants.PARAM_SOURCE.equals(nextParamName)) {
209                                IQueryParameterAnd<?> param = JpaParamUtil.parseQueryParams(
210                                                myFhirContext, RestSearchParameterTypeEnum.URI, nextParamName, paramList);
211                                paramMap.add(nextParamName, param);
212                        } else if (JpaConstants.PARAM_DELETE_EXPUNGE.equals(nextParamName)) {
213                                paramMap.setDeleteExpunge(true);
214                        } else if (Constants.PARAM_LIST.equals(nextParamName)) {
215                                IQueryParameterAnd<?> param = JpaParamUtil.parseQueryParams(
216                                                myFhirContext, RestSearchParameterTypeEnum.TOKEN, nextParamName, paramList);
217                                paramMap.add(nextParamName, param);
218                        } else if (nextParamName.startsWith("_") && !Constants.PARAM_LANGUAGE.equals(nextParamName)) {
219                                // ignore these since they aren't search params (e.g. _sort)
220                        } else {
221                                if (hasNoResourceType) {
222                                        // It is a resource specific search parameter being done on the server
223                                        throw new InvalidRequestException(Msg.code(2743) + "Failed to parse match URL [" + theMatchUrl
224                                                        + "] - Unknown search parameter " + nextParamName + " for operation on server base.");
225                                }
226
227                                RuntimeSearchParam paramDef = mySearchParamRegistry.getActiveSearchParam(
228                                                theResourceDefinition.getName(),
229                                                nextParamName,
230                                                ISearchParamRegistry.SearchParamLookupContextEnum.SEARCH);
231                                if (paramDef == null) {
232                                        throw throwUnrecognizedParamException(theMatchUrl, theResourceDefinition, nextParamName);
233                                }
234
235                                IQueryParameterAnd<?> param = JpaParamUtil.parseQueryParams(
236                                                mySearchParamRegistry, myFhirContext, paramDef, nextParamName, paramList);
237                                paramMap.add(nextParamName, param);
238                        }
239                }
240                return paramMap;
241        }
242
243        private static boolean isSupportedQueryForNoProvidedResourceType(Set<String> theParamNames) {
244                if (theParamNames == null || theParamNames.isEmpty()) {
245                        // Query with no resource type in URL (ie. `[server base]?`)
246                        return false;
247                }
248                Set<String> acceptableServerParams = new HashSet<>(STRICT_RESOURCE_META_PARAMS);
249                acceptableServerParams.add(Constants.PARAM_INCLUDE_DELETED);
250                return acceptableServerParams.containsAll(theParamNames);
251        }
252
253        private static boolean hasNoResourceTypeInUrl(String theMatchUrl, RuntimeResourceDefinition theResourceDefinition) {
254                return theResourceDefinition == null && theMatchUrl.indexOf('?') == 0;
255        }
256
257        /**
258         * The _includeDeleted parameter should only be supported with _lastUpdated, and _id iff resource type is given
259         * This is because these are the common search parameter values that are still stored on the deleted resource version in DB
260         * However, since resources are unique by type and id, only _lastUpdated is supported if no resource type is given
261         * @param theParamsToCheck the list of parameters found in the URL
262         * @param theHasNoResourceType whether the request is on the base URL (ie `?_param` - without resource type)
263         */
264        private static void validateParamsAreCompatibleForDeleteOrThrow(
265                        Set<String> theParamsToCheck, boolean theHasNoResourceType) {
266                Set<String> compatibleParams =
267                                theHasNoResourceType ? COMPATIBLE_PARAMS_NO_RES_TYPE : COMPATIBLE_PARAMS_GIVEN_RES_TYPE;
268
269                if (!compatibleParams.containsAll(theParamsToCheck)) {
270                        throw new IllegalArgumentException(Msg.code(2744) + "The " + Constants.PARAM_INCLUDE_DELETED
271                                        + " parameter is only compatible with the following parameters: " + compatibleParams);
272                }
273        }
274
275        public static class UnrecognizedSearchParameterException extends InvalidRequestException {
276
277                private final String myResourceName;
278                private final String myParamName;
279
280                UnrecognizedSearchParameterException(String theMessage, String theResourceName, String theParamName) {
281                        super(theMessage);
282                        myResourceName = theResourceName;
283                        myParamName = theParamName;
284                }
285
286                public String getResourceName() {
287                        return myResourceName;
288                }
289
290                public String getParamName() {
291                        return myParamName;
292                }
293        }
294
295        private InvalidRequestException throwUnrecognizedParamException(
296                        String theMatchUrl, RuntimeResourceDefinition theResourceDefinition, String nextParamName) {
297                return new UnrecognizedSearchParameterException(
298                                Msg.code(488) + "Failed to parse match URL[" + theMatchUrl + "] - Resource type "
299                                                + theResourceDefinition.getName() + " does not have a parameter with name: " + nextParamName,
300                                theResourceDefinition.getName(),
301                                nextParamName);
302        }
303
304        private IQueryParameterAnd<?> newInstanceAnd(String theParamType) {
305                Class<? extends IQueryParameterAnd<?>> clazz = ResourceMetaParams.RESOURCE_META_AND_PARAMS.get(theParamType);
306                return ReflectionUtil.newInstance(clazz);
307        }
308
309        public IQueryParameterType newInstanceType(String theParamType) {
310                Class<? extends IQueryParameterType> clazz = ResourceMetaParams.RESOURCE_META_PARAMS.get(theParamType);
311                return ReflectionUtil.newInstance(clazz);
312        }
313
314        public ResourceSearch getResourceSearch(String theUrl, RequestPartitionId theRequestPartitionId, Flag... theFlags) {
315                RuntimeResourceDefinition resourceDefinition;
316                resourceDefinition = UrlUtil.parseUrlResourceType(myFhirContext, theUrl);
317                SearchParameterMap searchParameterMap = translateMatchUrl(theUrl, resourceDefinition, theFlags);
318                return new ResourceSearch(resourceDefinition, searchParameterMap, theRequestPartitionId);
319        }
320
321        public ResourceSearch getResourceSearch(String theUrl) {
322                return getResourceSearch(theUrl, null);
323        }
324
325        /**
326         * Parse a URL that contains _include or _revinclude parameters and return a {@link ResourceSearch} object
327         * @param theUrl
328         * @return the ResourceSearch object that can be used to create a SearchParameterMap
329         */
330        public ResourceSearch getResourceSearchWithIncludesAndRevIncludes(String theUrl) {
331                return getResourceSearch(theUrl, null, MatchUrlService.processIncludes());
332        }
333
334        public interface Flag {
335                void process(String theParamName, List<QualifiedParamList> theValues, SearchParameterMap theMapToPopulate);
336        }
337
338        /**
339         * Indicates that the parser should process _include and _revinclude (by default these are not handled)
340         */
341        public static Flag processIncludes() {
342                return (theParamName, theValues, theMapToPopulate) -> {
343                        if (Constants.PARAM_INCLUDE.equals(theParamName)) {
344                                for (QualifiedParamList nextQualifiedList : theValues) {
345                                        for (String nextValue : nextQualifiedList) {
346                                                theMapToPopulate.addInclude(new Include(
347                                                                nextValue, ParameterUtil.isIncludeIterate(nextQualifiedList.getQualifier())));
348                                        }
349                                }
350                        } else if (Constants.PARAM_REVINCLUDE.equals(theParamName)) {
351                                for (QualifiedParamList nextQualifiedList : theValues) {
352                                        for (String nextValue : nextQualifiedList) {
353                                                theMapToPopulate.addRevInclude(new Include(
354                                                                nextValue, ParameterUtil.isIncludeIterate(nextQualifiedList.getQualifier())));
355                                        }
356                                }
357                        }
358                };
359        }
360
361        public record ResourceTypeAndSearchParameterMap(
362                        RuntimeResourceDefinition resourceDefinition, SearchParameterMap searchParameterMap) {}
363}