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