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