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