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}