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