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