
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}