001/*-
002 * #%L
003 * HAPI FHIR Storage api
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.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                if (resolvedInCache != null) {
122                        retVal = Collections.singleton(resolvedInCache);
123                }
124
125                if (retVal == null) {
126                        RuntimeResourceDefinition resourceDef = myContext.getResourceDefinition(theResourceType);
127                        SearchParameterMap paramMap = myMatchUrlService.translateMatchUrl(matchUrl, resourceDef);
128                        if (paramMap.isEmpty() && paramMap.getLastUpdated() == null) {
129                                throw new InvalidRequestException(
130                                                Msg.code(518) + "Invalid match URL[" + matchUrl + "] - URL has no search parameters");
131                        }
132                        paramMap.setLoadSynchronousUpTo(2);
133
134                        retVal = search(paramMap, theResourceType, theRequest, theConditionalOperationTargetOrNull);
135                }
136
137                // Interceptor broadcast: STORAGE_PRESHOW_RESOURCES
138                if (CompositeInterceptorBroadcaster.hasHooks(
139                                Pointcut.STORAGE_PRESHOW_RESOURCES, myInterceptorBroadcaster, theRequest)) {
140                        Map<IBaseResource, T> resourceToPidMap = new HashMap<>();
141
142                        IFhirResourceDao<R> dao = getResourceDao(theResourceType);
143
144                        for (T pid : retVal) {
145                                resourceToPidMap.put(dao.readByPid(pid), pid);
146                        }
147
148                        SimplePreResourceShowDetails accessDetails = new SimplePreResourceShowDetails(resourceToPidMap.keySet());
149                        HookParams params = new HookParams()
150                                        .add(IPreResourceShowDetails.class, accessDetails)
151                                        .add(RequestDetails.class, theRequest)
152                                        .addIfMatchesType(ServletRequestDetails.class, theRequest);
153
154                        try {
155                                CompositeInterceptorBroadcaster.doCallHooks(
156                                                myInterceptorBroadcaster, theRequest, Pointcut.STORAGE_PRESHOW_RESOURCES, params);
157
158                                retVal = accessDetails.toList().stream()
159                                                .map(resourceToPidMap::get)
160                                                .filter(Objects::nonNull)
161                                                .collect(Collectors.toSet());
162                        } catch (ForbiddenOperationException e) {
163                                // If the search matches a resource that the user does not have authorization for,
164                                // we want to treat it the same as if the search matched no resources, in order not to leak information.
165                                ourLog.warn(
166                                                "Inline match URL [" + matchUrl
167                                                                + "] specified a resource the user is not authorized to access.",
168                                                e);
169                                retVal = new HashSet<>();
170                        }
171                }
172
173                if (retVal.size() == 1) {
174                        T pid = retVal.iterator().next();
175                        theTransactionDetails.addResolvedMatchUrl(myContext, matchUrl, pid);
176                        if (myStorageSettings.isMatchUrlCacheEnabled()) {
177                                myMemoryCacheService.putAfterCommit(MemoryCacheService.CacheEnum.MATCH_URL, matchUrl, pid);
178                        }
179                }
180
181                return retVal;
182        }
183
184        private <R extends IBaseResource> IFhirResourceDao<R> getResourceDao(Class<R> theResourceType) {
185                IFhirResourceDao<R> dao = myDaoRegistry.getResourceDao(theResourceType);
186                if (dao == null) {
187                        throw new InternalErrorException(Msg.code(519) + "No DAO for resource type: " + theResourceType.getName());
188                }
189                return dao;
190        }
191
192        private String massageForStorage(String theResourceType, String theMatchUrl) {
193                Validate.notBlank(theMatchUrl, "theMatchUrl must not be null or blank");
194                int questionMarkIdx = theMatchUrl.indexOf("?");
195                if (questionMarkIdx > 0) {
196                        return theMatchUrl;
197                }
198                if (questionMarkIdx == 0) {
199                        return theResourceType + theMatchUrl;
200                }
201                return theResourceType + "?" + theMatchUrl;
202        }
203
204        @Nullable
205        public T processMatchUrlUsingCacheOnly(String theResourceType, String theMatchUrl) {
206                T existing = null;
207                if (myStorageSettings.isMatchUrlCacheEnabled()) {
208                        String matchUrl = massageForStorage(theResourceType, theMatchUrl);
209                        existing = myMemoryCacheService.getIfPresent(MemoryCacheService.CacheEnum.MATCH_URL, matchUrl);
210                }
211                return existing;
212        }
213
214        public <R extends IBaseResource> Set<T> search(
215                        SearchParameterMap theParamMap,
216                        Class<R> theResourceType,
217                        RequestDetails theRequest,
218                        @Nullable IBaseResource theConditionalOperationTargetOrNull) {
219                StopWatch sw = new StopWatch();
220                IFhirResourceDao<R> dao = getResourceDao(theResourceType);
221
222                List<T> retVal = dao.searchForIds(theParamMap, theRequest, theConditionalOperationTargetOrNull);
223
224                // Interceptor broadcast: JPA_PERFTRACE_INFO
225                if (CompositeInterceptorBroadcaster.hasHooks(
226                                Pointcut.JPA_PERFTRACE_INFO, myInterceptorBroadcaster, theRequest)) {
227                        StorageProcessingMessage message = new StorageProcessingMessage();
228                        message.setMessage("Processed conditional resource URL with " + retVal.size() + " result(s) in " + sw);
229                        HookParams params = new HookParams()
230                                        .add(RequestDetails.class, theRequest)
231                                        .addIfMatchesType(ServletRequestDetails.class, theRequest)
232                                        .add(StorageProcessingMessage.class, message);
233                        CompositeInterceptorBroadcaster.doCallHooks(
234                                        myInterceptorBroadcaster, theRequest, Pointcut.JPA_PERFTRACE_INFO, params);
235                }
236
237                return new HashSet<>(retVal);
238        }
239
240        public void matchUrlResolved(
241                        TransactionDetails theTransactionDetails,
242                        String theResourceType,
243                        String theMatchUrl,
244                        T theResourcePersistentId) {
245                Validate.notBlank(theMatchUrl);
246                Validate.notNull(theResourcePersistentId);
247                String matchUrl = massageForStorage(theResourceType, theMatchUrl);
248                theTransactionDetails.addResolvedMatchUrl(myContext, matchUrl, theResourcePersistentId);
249                if (myStorageSettings.isMatchUrlCacheEnabled()) {
250                        myMemoryCacheService.putAfterCommit(
251                                        MemoryCacheService.CacheEnum.MATCH_URL, matchUrl, theResourcePersistentId);
252                }
253        }
254
255        public void unresolveMatchUrl(
256                        TransactionDetails theTransactionDetails, String theResourceType, String theMatchUrl) {
257                Validate.notBlank(theMatchUrl);
258                String matchUrl = massageForStorage(theResourceType, theMatchUrl);
259                theTransactionDetails.removeResolvedMatchUrl(matchUrl);
260        }
261}