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.registry; 021 022import ca.uhn.fhir.context.ComboSearchParamType; 023import ca.uhn.fhir.context.FhirContext; 024import ca.uhn.fhir.context.RuntimeSearchParam; 025import ca.uhn.fhir.context.phonetic.IPhoneticEncoder; 026import ca.uhn.fhir.i18n.Msg; 027import ca.uhn.fhir.interceptor.api.IInterceptorService; 028import ca.uhn.fhir.jpa.cache.IResourceChangeEvent; 029import ca.uhn.fhir.jpa.cache.IResourceChangeListener; 030import ca.uhn.fhir.jpa.cache.IResourceChangeListenerCache; 031import ca.uhn.fhir.jpa.cache.IResourceChangeListenerRegistry; 032import ca.uhn.fhir.jpa.cache.ResourceChangeResult; 033import ca.uhn.fhir.jpa.model.config.PartitionSettings; 034import ca.uhn.fhir.jpa.model.entity.StorageSettings; 035import ca.uhn.fhir.jpa.model.search.ISearchParamHashIdentityRegistry; 036import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; 037import ca.uhn.fhir.rest.api.Constants; 038import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum; 039import ca.uhn.fhir.rest.api.server.IBundleProvider; 040import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException; 041import ca.uhn.fhir.rest.server.util.ISearchParamRegistry; 042import ca.uhn.fhir.rest.server.util.IndexedSearchParam; 043import ca.uhn.fhir.rest.server.util.ResourceSearchParams; 044import ca.uhn.fhir.util.SearchParameterUtil; 045import ca.uhn.fhir.util.StopWatch; 046import com.google.common.annotations.VisibleForTesting; 047import com.google.common.collect.Sets; 048import jakarta.annotation.Nonnull; 049import jakarta.annotation.Nullable; 050import jakarta.annotation.PostConstruct; 051import jakarta.annotation.PreDestroy; 052import org.apache.commons.lang3.StringUtils; 053import org.apache.commons.lang3.time.DateUtils; 054import org.hl7.fhir.instance.model.api.IBaseResource; 055import org.hl7.fhir.instance.model.api.IIdType; 056import org.slf4j.Logger; 057import org.slf4j.LoggerFactory; 058import org.springframework.beans.factory.annotation.Autowired; 059 060import java.util.ArrayList; 061import java.util.Collection; 062import java.util.Collections; 063import java.util.Iterator; 064import java.util.List; 065import java.util.Objects; 066import java.util.Optional; 067import java.util.Set; 068import java.util.stream.Collectors; 069 070import static ca.uhn.fhir.rest.server.util.ISearchParamRegistry.isAllowedForContext; 071import static org.apache.commons.lang3.StringUtils.isBlank; 072 073public class SearchParamRegistryImpl 074 implements ISearchParamRegistry, 075 IResourceChangeListener, 076 ISearchParamRegistryController, 077 ISearchParamHashIdentityRegistry { 078 079 public static final Set<String> NON_DISABLEABLE_SEARCH_PARAMS = 080 Collections.unmodifiableSet(Sets.newHashSet("*:url", "Subscription:*", "SearchParameter:*")); 081 082 private static final Logger ourLog = LoggerFactory.getLogger(SearchParamRegistryImpl.class); 083 public static final int MAX_MANAGED_PARAM_COUNT = 10000; 084 private static final long REFRESH_INTERVAL = DateUtils.MILLIS_PER_MINUTE; 085 086 private JpaSearchParamCache myJpaSearchParamCache; 087 088 @Autowired 089 private StorageSettings myStorageSettings; 090 091 @Autowired 092 private ISearchParamProvider mySearchParamProvider; 093 094 @Autowired 095 private FhirContext myFhirContext; 096 097 @Autowired 098 private SearchParameterCanonicalizer mySearchParameterCanonicalizer; 099 100 @Autowired 101 private IInterceptorService myInterceptorBroadcaster; 102 103 @Autowired 104 private IResourceChangeListenerRegistry myResourceChangeListenerRegistry; 105 106 @Autowired 107 private PartitionSettings myPartitionSettings; 108 109 private IResourceChangeListenerCache myResourceChangeListenerCache; 110 private volatile ReadOnlySearchParamCache myBuiltInSearchParams; 111 private volatile IPhoneticEncoder myPhoneticEncoder; 112 private volatile RuntimeSearchParamCache myActiveSearchParams; 113 114 /** 115 * Constructor 116 */ 117 public SearchParamRegistryImpl() { 118 super(); 119 } 120 121 @PostConstruct 122 public void start() { 123 myJpaSearchParamCache = new JpaSearchParamCache(myPartitionSettings); 124 } 125 126 @Override 127 public RuntimeSearchParam getActiveSearchParam( 128 @Nonnull String theResourceName, 129 @Nonnull String theParamName, 130 @Nonnull SearchParamLookupContextEnum theContext) { 131 requiresActiveSearchParams(); 132 133 // Can still be null in unit test scenarios 134 if (myActiveSearchParams != null) { 135 RuntimeSearchParam param = myActiveSearchParams.get(theResourceName, theParamName); 136 if (param != null) { 137 if (isAllowedForContext(param, theContext)) { 138 return param; 139 } 140 } 141 } 142 143 return null; 144 } 145 146 @Nonnull 147 @Override 148 public ResourceSearchParams getActiveSearchParams( 149 @Nonnull String theResourceName, @Nonnull SearchParamLookupContextEnum theContext) { 150 requiresActiveSearchParams(); 151 return getActiveSearchParams().getSearchParamMap(theResourceName).toFilteredForContext(theContext); 152 } 153 154 private void requiresActiveSearchParams() { 155 if (myActiveSearchParams == null) { 156 // forced refreshes should not use a cache - we're forcibly refreshing it, after all 157 myResourceChangeListenerCache.forceRefresh(); 158 } 159 } 160 161 @Override 162 public List<RuntimeSearchParam> getActiveComboSearchParams( 163 @Nonnull String theResourceName, @Nonnull SearchParamLookupContextEnum theContext) { 164 return filteredForContext(myJpaSearchParamCache.getActiveComboSearchParams(theResourceName), theContext); 165 } 166 167 @Override 168 public List<RuntimeSearchParam> getActiveComboSearchParams( 169 @Nonnull String theResourceName, 170 @Nonnull ComboSearchParamType theParamType, 171 @Nonnull SearchParamLookupContextEnum theContext) { 172 return filteredForContext( 173 myJpaSearchParamCache.getActiveComboSearchParams(theResourceName, theParamType), theContext); 174 } 175 176 @Override 177 public List<RuntimeSearchParam> getActiveComboSearchParams( 178 @Nonnull String theResourceName, 179 @Nonnull Set<String> theParamNames, 180 @Nonnull SearchParamLookupContextEnum theContext) { 181 return filteredForContext( 182 myJpaSearchParamCache.getActiveComboSearchParams(theResourceName, theParamNames), theContext); 183 } 184 185 @Override 186 public Optional<IndexedSearchParam> getIndexedSearchParamByHashIdentity(Long theHashIdentity) { 187 return myJpaSearchParamCache.getIndexedSearchParamByHashIdentity(theHashIdentity); 188 } 189 190 @Nullable 191 @Override 192 public RuntimeSearchParam getActiveSearchParamByUrl( 193 @Nonnull String theUrl, @Nonnull SearchParamLookupContextEnum theContext) { 194 if (myActiveSearchParams != null) { 195 RuntimeSearchParam param = myActiveSearchParams.getByUrl(theUrl); 196 if (isAllowedForContext(param, theContext)) { 197 return param; 198 } 199 } 200 return null; 201 } 202 203 @Override 204 public Optional<RuntimeSearchParam> getActiveComboSearchParamById( 205 @Nonnull String theResourceName, @Nonnull IIdType theId) { 206 return myJpaSearchParamCache.getActiveComboSearchParamById(theResourceName, theId); 207 } 208 209 private void rebuildActiveSearchParams() { 210 ourLog.info("Rebuilding SearchParamRegistry"); 211 SearchParameterMap params = new SearchParameterMap(); 212 params.setLoadSynchronousUpTo(MAX_MANAGED_PARAM_COUNT); 213 params.setCount(MAX_MANAGED_PARAM_COUNT); 214 215 IBundleProvider allSearchParamsBp = mySearchParamProvider.search(params); 216 217 List<IBaseResource> allSearchParams = allSearchParamsBp.getResources(0, MAX_MANAGED_PARAM_COUNT); 218 Integer size = allSearchParamsBp.size(); 219 220 ourLog.trace("Loaded {} search params from the DB", allSearchParams.size()); 221 222 if (size == null) { 223 ourLog.error( 224 "Only {} search parameters have been loaded, but there are more than that in the repository. Is offset search configured on this server?", 225 allSearchParams.size()); 226 } else if (size >= MAX_MANAGED_PARAM_COUNT) { 227 ourLog.warn("Unable to support >" + MAX_MANAGED_PARAM_COUNT + " search params!"); 228 } 229 230 initializeActiveSearchParams(allSearchParams); 231 } 232 233 private void initializeActiveSearchParams(Collection<IBaseResource> theJpaSearchParams) { 234 StopWatch sw = new StopWatch(); 235 236 ReadOnlySearchParamCache builtInSearchParams = getBuiltInSearchParams(); 237 RuntimeSearchParamCache searchParams = 238 RuntimeSearchParamCache.fromReadOnlySearchParamCache(builtInSearchParams); 239 long overriddenCount = overrideBuiltinSearchParamsWithActiveJpaSearchParams(searchParams, theJpaSearchParams); 240 ourLog.trace("Have overridden {} built-in search parameters", overriddenCount); 241 removeInactiveSearchParams(searchParams); 242 243 /* 244 * The _language SearchParameter is a weird exception - It is actually just a normal 245 * token SP, but we explcitly ban SPs from registering themselves with a prefix 246 * of "_" since that's system reserved so we put this one behind a settings toggle 247 */ 248 if (myStorageSettings.isLanguageSearchParameterEnabled()) { 249 IIdType id = myFhirContext.getVersion().newIdType(); 250 id.setValue("SearchParameter/Resource-language"); 251 RuntimeSearchParam sp = new RuntimeSearchParam( 252 id, 253 "http://hl7.org/fhir/SearchParameter/Resource-language", 254 Constants.PARAM_LANGUAGE, 255 "Language of the resource content", 256 "language", 257 RestSearchParameterTypeEnum.TOKEN, 258 Collections.emptySet(), 259 Collections.emptySet(), 260 RuntimeSearchParam.RuntimeSearchParamStatusEnum.ACTIVE, 261 myFhirContext.getResourceTypes()); 262 for (String baseResourceType : sp.getBase()) { 263 searchParams.add(baseResourceType, sp.getName(), sp); 264 } 265 } 266 267 setActiveSearchParams(searchParams); 268 269 myJpaSearchParamCache.populateActiveSearchParams( 270 myInterceptorBroadcaster, myPhoneticEncoder, myActiveSearchParams); 271 ourLog.debug("Refreshed search parameter cache in {}ms", sw.getMillis()); 272 } 273 274 @VisibleForTesting 275 public void setFhirContext(FhirContext theFhirContext) { 276 myFhirContext = theFhirContext; 277 } 278 279 private ReadOnlySearchParamCache getBuiltInSearchParams() { 280 if (myBuiltInSearchParams == null) { 281 if (myStorageSettings.isAutoSupportDefaultSearchParams()) { 282 myBuiltInSearchParams = 283 ReadOnlySearchParamCache.fromFhirContext(myFhirContext, mySearchParameterCanonicalizer); 284 } else { 285 // Only the built-in search params that can not be disabled will be supported automatically 286 myBuiltInSearchParams = ReadOnlySearchParamCache.fromFhirContext( 287 myFhirContext, mySearchParameterCanonicalizer, NON_DISABLEABLE_SEARCH_PARAMS); 288 } 289 } 290 return myBuiltInSearchParams; 291 } 292 293 private void removeInactiveSearchParams(RuntimeSearchParamCache theSearchParams) { 294 for (String resourceName : theSearchParams.getResourceNameKeys()) { 295 ResourceSearchParams resourceSearchParams = theSearchParams.getSearchParamMap(resourceName); 296 resourceSearchParams.removeInactive(); 297 } 298 } 299 300 @VisibleForTesting 301 public void setStorageSettings(StorageSettings theStorageSettings) { 302 myStorageSettings = theStorageSettings; 303 } 304 305 private long overrideBuiltinSearchParamsWithActiveJpaSearchParams( 306 RuntimeSearchParamCache theSearchParamCache, Collection<IBaseResource> theSearchParams) { 307 if (!myStorageSettings.isDefaultSearchParamsCanBeOverridden() || theSearchParams == null) { 308 return 0; 309 } 310 311 long retval = 0; 312 for (IBaseResource searchParam : theSearchParams) { 313 retval += overrideSearchParam(theSearchParamCache, searchParam); 314 } 315 return retval; 316 } 317 318 private long overrideSearchParam(RuntimeSearchParamCache theSearchParams, IBaseResource theSearchParameter) { 319 if (theSearchParameter == null) { 320 return 0; 321 } 322 323 RuntimeSearchParam runtimeSp = mySearchParameterCanonicalizer.canonicalizeSearchParameter(theSearchParameter); 324 if (runtimeSp == null) { 325 return 0; 326 } 327 if (runtimeSp.getStatus() == RuntimeSearchParam.RuntimeSearchParamStatusEnum.DRAFT) { 328 return 0; 329 } 330 331 long retval = 0; 332 for (String nextBaseName : SearchParameterUtil.getBaseAsStrings(myFhirContext, theSearchParameter)) { 333 if (isBlank(nextBaseName)) { 334 continue; 335 } 336 337 String name = runtimeSp.getName(); 338 339 theSearchParams.add(nextBaseName, name, runtimeSp); 340 ourLog.debug( 341 "Adding search parameter {}.{} to SearchParamRegistry", 342 nextBaseName, 343 Objects.toString(name, "[composite]")); 344 retval++; 345 } 346 return retval; 347 } 348 349 @Override 350 public void requestRefresh() { 351 myResourceChangeListenerCache.requestRefresh(); 352 } 353 354 @Override 355 public void forceRefresh() { 356 RuntimeSearchParamCache activeSearchParams = myActiveSearchParams; 357 myResourceChangeListenerCache.forceRefresh(); 358 359 // If the refresh didn't trigger a change, proceed with one anyway 360 if (myActiveSearchParams == activeSearchParams) { 361 rebuildActiveSearchParams(); 362 } 363 } 364 365 @Override 366 public ResourceChangeResult refreshCacheIfNecessary() { 367 return myResourceChangeListenerCache.refreshCacheIfNecessary(); 368 } 369 370 @VisibleForTesting 371 public void setResourceChangeListenerRegistry(IResourceChangeListenerRegistry theResourceChangeListenerRegistry) { 372 myResourceChangeListenerRegistry = theResourceChangeListenerRegistry; 373 } 374 375 /** 376 * There is a circular reference between this class and the ResourceChangeListenerRegistry: 377 * SearchParamRegistryImpl -> ResourceChangeListenerRegistry -> InMemoryResourceMatcher -> SearchParamRegistryImpl. Since we only need this once on boot-up, we delay 378 * until ContextRefreshedEvent. 379 */ 380 @PostConstruct 381 public void registerListener() { 382 SearchParameterMap spMap = SearchParameterMap.newSynchronous(); 383 spMap.setLoadSynchronousUpTo(MAX_MANAGED_PARAM_COUNT); 384 myResourceChangeListenerCache = myResourceChangeListenerRegistry.registerResourceResourceChangeListener( 385 "SearchParameter", spMap, this, REFRESH_INTERVAL); 386 } 387 388 @PreDestroy 389 public void unregisterListener() { 390 myResourceChangeListenerRegistry.unregisterResourceResourceChangeListener(this); 391 } 392 393 public ReadOnlySearchParamCache getActiveSearchParams() { 394 requiresActiveSearchParams(); 395 if (myActiveSearchParams == null) { 396 throw new IllegalStateException(Msg.code(511) + "SearchParamRegistry has not been initialized"); 397 } 398 return ReadOnlySearchParamCache.fromRuntimeSearchParamCache(myActiveSearchParams); 399 } 400 401 @VisibleForTesting 402 public void setActiveSearchParams(RuntimeSearchParamCache theSearchParams) { 403 myActiveSearchParams = theSearchParams; 404 } 405 406 /** 407 * All SearchParameters with the name "phonetic" encode the normalized index value using this phonetic encoder. 408 * 409 * @since 5.1.0 410 */ 411 @Override 412 public void setPhoneticEncoder(IPhoneticEncoder thePhoneticEncoder) { 413 myPhoneticEncoder = thePhoneticEncoder; 414 415 if (myActiveSearchParams == null) { 416 return; 417 } 418 myActiveSearchParams 419 .getSearchParamStream() 420 .forEach(searchParam -> myJpaSearchParamCache.setPhoneticEncoder(myPhoneticEncoder, searchParam)); 421 } 422 423 @Override 424 public void handleChange(IResourceChangeEvent theResourceChangeEvent) { 425 if (theResourceChangeEvent.isEmpty()) { 426 return; 427 } 428 429 ResourceChangeResult result = ResourceChangeResult.fromResourceChangeEvent(theResourceChangeEvent); 430 if (result.created > 0) { 431 ourLog.info( 432 "Adding {} search parameters to SearchParamRegistry: {}", 433 result.created, 434 unqualified(theResourceChangeEvent.getCreatedResourceIds())); 435 } 436 if (result.updated > 0) { 437 ourLog.info( 438 "Updating {} search parameters in SearchParamRegistry: {}", 439 result.updated, 440 unqualified(theResourceChangeEvent.getUpdatedResourceIds())); 441 } 442 if (result.deleted > 0) { 443 ourLog.info( 444 "Deleting {} search parameters from SearchParamRegistry: {}", 445 result.deleted, 446 unqualified(theResourceChangeEvent.getDeletedResourceIds())); 447 } 448 rebuildActiveSearchParams(); 449 } 450 451 private String unqualified(List<IIdType> theIds) { 452 Iterator<String> unqualifiedIds = theIds.stream() 453 .map(IIdType::toUnqualifiedVersionless) 454 .map(IIdType::getValue) 455 .iterator(); 456 457 return StringUtils.join(unqualifiedIds, ", "); 458 } 459 460 @Override 461 public void handleInit(Collection<IIdType> theResourceIds) { 462 List<IBaseResource> searchParams = new ArrayList<>(); 463 for (IIdType id : theResourceIds) { 464 try { 465 IBaseResource searchParam = mySearchParamProvider.read(id); 466 searchParams.add(searchParam); 467 } catch (ResourceNotFoundException e) { 468 ourLog.warn("SearchParameter {} not found. Excluding from list of active search params.", id); 469 } 470 } 471 initializeActiveSearchParams(searchParams); 472 } 473 474 @Override 475 public boolean isInitialized() { 476 return myActiveSearchParams != null; 477 } 478 479 @VisibleForTesting 480 public void resetForUnitTest() { 481 myBuiltInSearchParams = null; 482 setActiveSearchParams(null); 483 handleInit(Collections.emptyList()); 484 } 485 486 @VisibleForTesting 487 public void setSearchParameterCanonicalizerForUnitTest( 488 SearchParameterCanonicalizer theSearchParameterCanonicalizerForUnitTest) { 489 mySearchParameterCanonicalizer = theSearchParameterCanonicalizerForUnitTest; 490 } 491 492 private static List<RuntimeSearchParam> filteredForContext( 493 List<RuntimeSearchParam> theActiveComboSearchParams, SearchParamLookupContextEnum theContext) { 494 return theActiveComboSearchParams.stream() 495 .filter(t -> isAllowedForContext(t, theContext)) 496 .collect(Collectors.toList()); 497 } 498}