001/*-
002 * #%L
003 * HAPI FHIR Storage api
004 * %%
005 * Copyright (C) 2014 - 2025 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.dao;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.context.RuntimeResourceDefinition;
024import ca.uhn.fhir.i18n.Msg;
025import ca.uhn.fhir.interceptor.api.HookParams;
026import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster;
027import ca.uhn.fhir.interceptor.api.Pointcut;
028import ca.uhn.fhir.interceptor.model.RequestPartitionId;
029import ca.uhn.fhir.jpa.api.config.JpaStorageSettings;
030import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
031import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
032import ca.uhn.fhir.jpa.model.search.StorageProcessingMessage;
033import ca.uhn.fhir.jpa.searchparam.MatchUrlService;
034import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
035import ca.uhn.fhir.jpa.util.MemoryCacheService;
036import ca.uhn.fhir.rest.api.server.IPreResourceShowDetails;
037import ca.uhn.fhir.rest.api.server.RequestDetails;
038import ca.uhn.fhir.rest.api.server.SimplePreResourceShowDetails;
039import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId;
040import ca.uhn.fhir.rest.api.server.storage.TransactionDetails;
041import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException;
042import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
043import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
044import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
045import ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster;
046import ca.uhn.fhir.util.StopWatch;
047import jakarta.annotation.Nonnull;
048import jakarta.annotation.Nullable;
049import org.apache.commons.lang3.Validate;
050import org.hl7.fhir.instance.model.api.IBaseResource;
051import org.springframework.beans.factory.annotation.Autowired;
052import org.springframework.stereotype.Service;
053
054import java.util.Collections;
055import java.util.HashMap;
056import java.util.HashSet;
057import java.util.List;
058import java.util.Map;
059import java.util.Objects;
060import java.util.Set;
061import java.util.function.Supplier;
062import java.util.stream.Collectors;
063
064@Service
065public class MatchResourceUrlService<T extends IResourcePersistentId<?>> {
066
067        private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(MatchResourceUrlService.class);
068        private static final String MATCH_URL_QUERY_USER_DATA_KEY =
069                        MatchResourceUrlService.class.getName() + ".MATCH_URL_QUERY";
070
071        @Autowired
072        private DaoRegistry myDaoRegistry;
073
074        @Autowired
075        private FhirContext myContext;
076
077        @Autowired
078        private MatchUrlService myMatchUrlService;
079
080        @Autowired
081        private JpaStorageSettings myStorageSettings;
082
083        @Autowired
084        private IInterceptorBroadcaster myInterceptorBroadcaster;
085
086        @Autowired
087        private MemoryCacheService myMemoryCacheService;
088
089        /**
090         * Note that this will only return a maximum of 2 results!!
091         */
092        public <R extends IBaseResource> Set<T> processMatchUrl(
093                        String theMatchUrl,
094                        Class<R> theResourceType,
095                        TransactionDetails theTransactionDetails,
096                        RequestDetails theRequest,
097                        RequestPartitionId thePartitionId) {
098                return processMatchUrl(theMatchUrl, theResourceType, theTransactionDetails, theRequest, null, thePartitionId);
099        }
100
101        /**
102         * Note that this will only return a maximum of 2 results!!
103         */
104        public <R extends IBaseResource> Set<T> processMatchUrl(
105                        String theMatchUrl,
106                        Class<R> theResourceType,
107                        TransactionDetails theTransactionDetails,
108                        RequestDetails theRequest,
109                        IBaseResource theConditionalOperationTargetOrNull,
110                        RequestPartitionId thePartitionId) {
111                Set<T> retVal = null;
112
113                String resourceType = myContext.getResourceType(theResourceType);
114                String matchUrl = massageForStorage(resourceType, theMatchUrl);
115
116                @SuppressWarnings("unchecked")
117                T resolvedInTransaction =
118                                (T) theTransactionDetails.getResolvedMatchUrls().get(matchUrl);
119                if (resolvedInTransaction != null) {
120                        // If the resource has previously been looked up within the transaction, there's no need to re-authorize it.
121                        if (resolvedInTransaction == TransactionDetails.NOT_FOUND) {
122                                return Collections.emptySet();
123                        } else {
124                                return Collections.singleton(resolvedInTransaction);
125                        }
126                }
127
128                T resolvedInCache = processMatchUrlUsingCacheOnly(resourceType, matchUrl, thePartitionId);
129
130                ourLog.debug("Resolving match URL from cache {} found: {}", theMatchUrl, resolvedInCache);
131                if (resolvedInCache != null) {
132                        retVal = Collections.singleton(resolvedInCache);
133                }
134
135                if (retVal == null) {
136                        RuntimeResourceDefinition resourceDef = myContext.getResourceDefinition(theResourceType);
137                        SearchParameterMap paramMap = myMatchUrlService.translateMatchUrl(matchUrl, resourceDef);
138                        if (paramMap.isEmpty() && paramMap.getLastUpdated() == null) {
139                                throw new InvalidRequestException(
140                                                Msg.code(518) + "Invalid match URL[" + matchUrl + "] - URL has no search parameters");
141                        }
142                        paramMap.setLoadSynchronousUpTo(2);
143
144                        retVal = callWithSpanMarker(
145                                        theRequest,
146                                        () -> search(paramMap, theResourceType, theRequest, theConditionalOperationTargetOrNull));
147                }
148
149                /*
150                 * We don't invoke the STORAGE_PRE_SHOW_RESOURCES hooks if we're either in mass ingestion mode,
151                 * or the match URL cache is enabled. This is a performance optimization with the following
152                 * justification:
153                 * Invoking this hook can be good since it means the AuthorizationInterceptor and ConsentInterceptor
154                 * are able to block actions where conditional URLs might reveal existence of a resource. But
155                 * it is bad because it means we need to fetch and parse the body associated with the resource
156                 * ID which can really hurt performance if we're doing a lot of conditional operations, and
157                 * most people don't need to defend against things being revealed by conditional URL
158                 * evaluation.
159                 */
160                if (!myStorageSettings.isMassIngestionMode() && !myStorageSettings.isMatchUrlCacheEnabled()) {
161                        retVal = invokePreShowInterceptorForMatchUrlResults(theResourceType, theRequest, retVal, matchUrl);
162                }
163
164                if (retVal.size() == 1) {
165                        T pid = retVal.iterator().next();
166                        theTransactionDetails.addResolvedMatchUrl(myContext, matchUrl, pid);
167                        if (myStorageSettings.isMatchUrlCacheEnabled()) {
168                                myMemoryCacheService.putAfterCommit(MemoryCacheService.CacheEnum.MATCH_URL, matchUrl, pid);
169                        }
170                }
171
172                return retVal;
173        }
174
175        <R> R callWithSpanMarker(RequestDetails theRequest, Supplier<R> theSupplier) {
176                try {
177                        if (theRequest != null) {
178                                theRequest.getUserData().put(MATCH_URL_QUERY_USER_DATA_KEY, MATCH_URL_QUERY_USER_DATA_KEY);
179                        }
180                        return theSupplier.get();
181                } finally {
182                        if (theRequest != null) {
183                                theRequest.getUserData().remove(MATCH_URL_QUERY_USER_DATA_KEY);
184                        }
185                }
186        }
187
188        /**
189         * Are we currently processing a match URL query?
190         * @param theRequest holds our private flag
191         * @return true if we are currently processing a match URL query, false otherwise
192         */
193        public static boolean isDuringMatchUrlQuerySpan(@Nonnull RequestDetails theRequest) {
194                return theRequest.getUserData().containsKey(MATCH_URL_QUERY_USER_DATA_KEY);
195        }
196
197        private <R extends IBaseResource> Set<T> invokePreShowInterceptorForMatchUrlResults(
198                        Class<R> theResourceType, RequestDetails theRequest, Set<T> retVal, String matchUrl) {
199                // Interceptor broadcast: STORAGE_PRESHOW_RESOURCES
200                IInterceptorBroadcaster compositeBroadcaster =
201                                CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, theRequest);
202                if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PRESHOW_RESOURCES)) {
203                        Map<IBaseResource, T> resourceToPidMap = new HashMap<>();
204
205                        IFhirResourceDao<R> dao = getResourceDao(theResourceType);
206
207                        for (T pid : retVal) {
208                                resourceToPidMap.put(dao.readByPid(pid), pid);
209                        }
210
211                        SimplePreResourceShowDetails accessDetails = new SimplePreResourceShowDetails(resourceToPidMap.keySet());
212
213                        HookParams params = new HookParams()
214                                        .add(IPreResourceShowDetails.class, accessDetails)
215                                        .add(RequestDetails.class, theRequest)
216                                        .addIfMatchesType(ServletRequestDetails.class, theRequest);
217
218                        try {
219                                compositeBroadcaster.callHooks(Pointcut.STORAGE_PRESHOW_RESOURCES, params);
220
221                                retVal = accessDetails.toList().stream()
222                                                .map(resourceToPidMap::get)
223                                                .filter(Objects::nonNull)
224                                                .collect(Collectors.toSet());
225                        } catch (ForbiddenOperationException e) {
226                                // If the search matches a resource that the user does not have authorization for,
227                                // we want to treat it the same as if the search matched no resources, in order not to leak information.
228                                ourLog.warn(
229                                                "Inline match URL [" + matchUrl
230                                                                + "] specified a resource the user is not authorized to access.",
231                                                e);
232                                retVal = new HashSet<>();
233                        }
234                }
235                return retVal;
236        }
237
238        private <R extends IBaseResource> IFhirResourceDao<R> getResourceDao(Class<R> theResourceType) {
239                IFhirResourceDao<R> dao = myDaoRegistry.getResourceDao(theResourceType);
240                if (dao == null) {
241                        throw new InternalErrorException(Msg.code(519) + "No DAO for resource type: " + theResourceType.getName());
242                }
243                return dao;
244        }
245
246        private String massageForStorage(String theResourceType, String theMatchUrl) {
247                Validate.notBlank(theMatchUrl, "theMatchUrl must not be null or blank");
248                int questionMarkIdx = theMatchUrl.indexOf("?");
249                if (questionMarkIdx > 0) {
250                        return theMatchUrl;
251                }
252                if (questionMarkIdx == 0) {
253                        return theResourceType + theMatchUrl;
254                }
255                return theResourceType + "?" + theMatchUrl;
256        }
257
258        @Nullable
259        public T processMatchUrlUsingCacheOnly(
260                        String theResourceType, String theMatchUrl, RequestPartitionId thePartitionId) {
261                T existing = null;
262                if (myStorageSettings.isMatchUrlCacheEnabled()) {
263                        String matchUrl = massageForStorage(theResourceType, theMatchUrl);
264                        T potentialMatch = myMemoryCacheService.getIfPresent(MemoryCacheService.CacheEnum.MATCH_URL, matchUrl);
265                        if (potentialMatch != null
266                                        && (thePartitionId.isAllPartitions()
267                                                        || (thePartitionId.hasPartitionIds()
268                                                                        && thePartitionId.hasPartitionId(potentialMatch.getPartitionId())))) {
269                                existing = potentialMatch;
270                        }
271                }
272                return existing;
273        }
274
275        public <R extends IBaseResource> Set<T> search(
276                        SearchParameterMap theParamMap,
277                        Class<R> theResourceType,
278                        RequestDetails theRequest,
279                        @Nullable IBaseResource theConditionalOperationTargetOrNull) {
280                StopWatch sw = new StopWatch();
281                IFhirResourceDao<R> dao = getResourceDao(theResourceType);
282
283                List<T> retVal = dao.searchForIds(theParamMap, theRequest, theConditionalOperationTargetOrNull);
284
285                // Interceptor broadcast: JPA_PERFTRACE_INFO
286                IInterceptorBroadcaster compositeBroadcaster =
287                                CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, theRequest);
288                if (compositeBroadcaster.hasHooks(Pointcut.JPA_PERFTRACE_INFO)) {
289                        StorageProcessingMessage message = new StorageProcessingMessage();
290                        message.setMessage("Processed conditional resource URL with " + retVal.size() + " result(s) in " + sw);
291                        HookParams params = new HookParams()
292                                        .add(RequestDetails.class, theRequest)
293                                        .addIfMatchesType(ServletRequestDetails.class, theRequest)
294                                        .add(StorageProcessingMessage.class, message);
295                        compositeBroadcaster.callHooks(Pointcut.JPA_PERFTRACE_INFO, params);
296                }
297
298                return new HashSet<>(retVal);
299        }
300
301        public void matchUrlResolved(
302                        TransactionDetails theTransactionDetails,
303                        String theResourceType,
304                        String theMatchUrl,
305                        T theResourcePersistentId) {
306                Validate.notBlank(theMatchUrl);
307                Validate.notNull(theResourcePersistentId);
308                String matchUrl = massageForStorage(theResourceType, theMatchUrl);
309                theTransactionDetails.addResolvedMatchUrl(myContext, matchUrl, theResourcePersistentId);
310                if (myStorageSettings.isMatchUrlCacheEnabled()) {
311                        myMemoryCacheService.putAfterCommit(
312                                        MemoryCacheService.CacheEnum.MATCH_URL, matchUrl, theResourcePersistentId);
313                }
314        }
315
316        public void unresolveMatchUrl(
317                        TransactionDetails theTransactionDetails, String theResourceType, String theMatchUrl) {
318                Validate.notBlank(theMatchUrl);
319                String matchUrl = massageForStorage(theResourceType, theMatchUrl);
320                theTransactionDetails.removeResolvedMatchUrl(matchUrl);
321        }
322}