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.jpa.api.config.JpaStorageSettings;
029import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
030import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
031import ca.uhn.fhir.jpa.model.search.StorageProcessingMessage;
032import ca.uhn.fhir.jpa.searchparam.MatchUrlService;
033import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
034import ca.uhn.fhir.jpa.util.MemoryCacheService;
035import ca.uhn.fhir.rest.api.server.IPreResourceShowDetails;
036import ca.uhn.fhir.rest.api.server.RequestDetails;
037import ca.uhn.fhir.rest.api.server.SimplePreResourceShowDetails;
038import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId;
039import ca.uhn.fhir.rest.api.server.storage.TransactionDetails;
040import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException;
041import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
042import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
043import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
044import ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster;
045import ca.uhn.fhir.util.StopWatch;
046import jakarta.annotation.Nullable;
047import org.apache.commons.lang3.Validate;
048import org.hl7.fhir.instance.model.api.IBaseResource;
049import org.springframework.beans.factory.annotation.Autowired;
050import org.springframework.stereotype.Service;
051
052import java.util.Collections;
053import java.util.HashMap;
054import java.util.HashSet;
055import java.util.List;
056import java.util.Map;
057import java.util.Objects;
058import java.util.Set;
059import java.util.stream.Collectors;
060
061@Service
062public class MatchResourceUrlService<T extends IResourcePersistentId> {
063
064        private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(MatchResourceUrlService.class);
065
066        @Autowired
067        private DaoRegistry myDaoRegistry;
068
069        @Autowired
070        private FhirContext myContext;
071
072        @Autowired
073        private MatchUrlService myMatchUrlService;
074
075        @Autowired
076        private JpaStorageSettings myStorageSettings;
077
078        @Autowired
079        private IInterceptorBroadcaster myInterceptorBroadcaster;
080
081        @Autowired
082        private MemoryCacheService myMemoryCacheService;
083
084        /**
085         * Note that this will only return a maximum of 2 results!!
086         */
087        public <R extends IBaseResource> Set<T> processMatchUrl(
088                        String theMatchUrl,
089                        Class<R> theResourceType,
090                        TransactionDetails theTransactionDetails,
091                        RequestDetails theRequest) {
092                return processMatchUrl(theMatchUrl, theResourceType, theTransactionDetails, theRequest, null);
093        }
094
095        /**
096         * Note that this will only return a maximum of 2 results!!
097         */
098        public <R extends IBaseResource> Set<T> processMatchUrl(
099                        String theMatchUrl,
100                        Class<R> theResourceType,
101                        TransactionDetails theTransactionDetails,
102                        RequestDetails theRequest,
103                        IBaseResource theConditionalOperationTargetOrNull) {
104                Set<T> retVal = null;
105
106                String resourceType = myContext.getResourceType(theResourceType);
107                String matchUrl = massageForStorage(resourceType, theMatchUrl);
108
109                T resolvedInTransaction =
110                                (T) theTransactionDetails.getResolvedMatchUrls().get(matchUrl);
111                if (resolvedInTransaction != null) {
112                        // If the resource has previously been looked up within the transaction, there's no need to re-authorize it.
113                        if (resolvedInTransaction == TransactionDetails.NOT_FOUND) {
114                                return Collections.emptySet();
115                        } else {
116                                return Collections.singleton(resolvedInTransaction);
117                        }
118                }
119
120                T resolvedInCache = processMatchUrlUsingCacheOnly(resourceType, matchUrl);
121                ourLog.debug("Resolving match URL from cache {} found: {}", theMatchUrl, resolvedInCache);
122                if (resolvedInCache != null) {
123                        retVal = Collections.singleton(resolvedInCache);
124                }
125
126                if (retVal == null) {
127                        RuntimeResourceDefinition resourceDef = myContext.getResourceDefinition(theResourceType);
128                        SearchParameterMap paramMap = myMatchUrlService.translateMatchUrl(matchUrl, resourceDef);
129                        if (paramMap.isEmpty() && paramMap.getLastUpdated() == null) {
130                                throw new InvalidRequestException(
131                                                Msg.code(518) + "Invalid match URL[" + matchUrl + "] - URL has no search parameters");
132                        }
133                        paramMap.setLoadSynchronousUpTo(2);
134
135                        retVal = search(paramMap, theResourceType, theRequest, theConditionalOperationTargetOrNull);
136                }
137
138                // Interceptor broadcast: STORAGE_PRESHOW_RESOURCES
139                IInterceptorBroadcaster compositeBroadcaster =
140                                CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, theRequest);
141                if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PRESHOW_RESOURCES)) {
142                        Map<IBaseResource, T> resourceToPidMap = new HashMap<>();
143
144                        IFhirResourceDao<R> dao = getResourceDao(theResourceType);
145
146                        for (T pid : retVal) {
147                                resourceToPidMap.put(dao.readByPid(pid), pid);
148                        }
149
150                        SimplePreResourceShowDetails accessDetails = new SimplePreResourceShowDetails(resourceToPidMap.keySet());
151                        HookParams params = new HookParams()
152                                        .add(IPreResourceShowDetails.class, accessDetails)
153                                        .add(RequestDetails.class, theRequest)
154                                        .addIfMatchesType(ServletRequestDetails.class, theRequest);
155
156                        try {
157                                compositeBroadcaster.callHooks(Pointcut.STORAGE_PRESHOW_RESOURCES, params);
158
159                                retVal = accessDetails.toList().stream()
160                                                .map(resourceToPidMap::get)
161                                                .filter(Objects::nonNull)
162                                                .collect(Collectors.toSet());
163                        } catch (ForbiddenOperationException e) {
164                                // If the search matches a resource that the user does not have authorization for,
165                                // we want to treat it the same as if the search matched no resources, in order not to leak information.
166                                ourLog.warn(
167                                                "Inline match URL [" + matchUrl
168                                                                + "] specified a resource the user is not authorized to access.",
169                                                e);
170                                retVal = new HashSet<>();
171                        }
172                }
173
174                if (retVal.size() == 1) {
175                        T pid = retVal.iterator().next();
176                        theTransactionDetails.addResolvedMatchUrl(myContext, matchUrl, pid);
177                        if (myStorageSettings.isMatchUrlCacheEnabled()) {
178                                myMemoryCacheService.putAfterCommit(MemoryCacheService.CacheEnum.MATCH_URL, matchUrl, pid);
179                        }
180                }
181
182                return retVal;
183        }
184
185        private <R extends IBaseResource> IFhirResourceDao<R> getResourceDao(Class<R> theResourceType) {
186                IFhirResourceDao<R> dao = myDaoRegistry.getResourceDao(theResourceType);
187                if (dao == null) {
188                        throw new InternalErrorException(Msg.code(519) + "No DAO for resource type: " + theResourceType.getName());
189                }
190                return dao;
191        }
192
193        private String massageForStorage(String theResourceType, String theMatchUrl) {
194                Validate.notBlank(theMatchUrl, "theMatchUrl must not be null or blank");
195                int questionMarkIdx = theMatchUrl.indexOf("?");
196                if (questionMarkIdx > 0) {
197                        return theMatchUrl;
198                }
199                if (questionMarkIdx == 0) {
200                        return theResourceType + theMatchUrl;
201                }
202                return theResourceType + "?" + theMatchUrl;
203        }
204
205        @Nullable
206        public T processMatchUrlUsingCacheOnly(String theResourceType, String theMatchUrl) {
207                T existing = null;
208                if (myStorageSettings.isMatchUrlCacheEnabled()) {
209                        String matchUrl = massageForStorage(theResourceType, theMatchUrl);
210                        existing = myMemoryCacheService.getIfPresent(MemoryCacheService.CacheEnum.MATCH_URL, matchUrl);
211                }
212                return existing;
213        }
214
215        public <R extends IBaseResource> Set<T> search(
216                        SearchParameterMap theParamMap,
217                        Class<R> theResourceType,
218                        RequestDetails theRequest,
219                        @Nullable IBaseResource theConditionalOperationTargetOrNull) {
220                StopWatch sw = new StopWatch();
221                IFhirResourceDao<R> dao = getResourceDao(theResourceType);
222
223                List<T> retVal = dao.searchForIds(theParamMap, theRequest, theConditionalOperationTargetOrNull);
224
225                // Interceptor broadcast: JPA_PERFTRACE_INFO
226                IInterceptorBroadcaster compositeBroadcaster =
227                                CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, theRequest);
228                if (compositeBroadcaster.hasHooks(Pointcut.JPA_PERFTRACE_INFO)) {
229                        StorageProcessingMessage message = new StorageProcessingMessage();
230                        message.setMessage("Processed conditional resource URL with " + retVal.size() + " result(s) in " + sw);
231                        HookParams params = new HookParams()
232                                        .add(RequestDetails.class, theRequest)
233                                        .addIfMatchesType(ServletRequestDetails.class, theRequest)
234                                        .add(StorageProcessingMessage.class, message);
235                        compositeBroadcaster.callHooks(Pointcut.JPA_PERFTRACE_INFO, params);
236                }
237
238                return new HashSet<>(retVal);
239        }
240
241        public void matchUrlResolved(
242                        TransactionDetails theTransactionDetails,
243                        String theResourceType,
244                        String theMatchUrl,
245                        T theResourcePersistentId) {
246                Validate.notBlank(theMatchUrl);
247                Validate.notNull(theResourcePersistentId);
248                String matchUrl = massageForStorage(theResourceType, theMatchUrl);
249                theTransactionDetails.addResolvedMatchUrl(myContext, matchUrl, theResourcePersistentId);
250                if (myStorageSettings.isMatchUrlCacheEnabled()) {
251                        myMemoryCacheService.putAfterCommit(
252                                        MemoryCacheService.CacheEnum.MATCH_URL, matchUrl, theResourcePersistentId);
253                }
254        }
255
256        public void unresolveMatchUrl(
257                        TransactionDetails theTransactionDetails, String theResourceType, String theMatchUrl) {
258                Validate.notBlank(theMatchUrl);
259                String matchUrl = massageForStorage(theResourceType, theMatchUrl);
260                theTransactionDetails.removeResolvedMatchUrl(matchUrl);
261        }
262}