
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}