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