
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.FhirVersionEnum; 024import ca.uhn.fhir.context.RuntimeResourceDefinition; 025import ca.uhn.fhir.i18n.Msg; 026import ca.uhn.fhir.interceptor.api.HookParams; 027import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster; 028import ca.uhn.fhir.interceptor.api.Pointcut; 029import ca.uhn.fhir.interceptor.model.ReadPartitionIdRequestDetails; 030import ca.uhn.fhir.interceptor.model.RequestPartitionId; 031import ca.uhn.fhir.interceptor.model.TransactionWriteOperationsDetails; 032import ca.uhn.fhir.jpa.api.dao.DaoRegistry; 033import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; 034import ca.uhn.fhir.jpa.api.dao.IJpaDao; 035import ca.uhn.fhir.jpa.api.model.DaoMethodOutcome; 036import ca.uhn.fhir.jpa.api.model.DeleteConflict; 037import ca.uhn.fhir.jpa.api.model.DeleteConflictList; 038import ca.uhn.fhir.jpa.api.model.DeleteMethodOutcome; 039import ca.uhn.fhir.jpa.api.model.LazyDaoMethodOutcome; 040import ca.uhn.fhir.jpa.cache.IResourceVersionSvc; 041import ca.uhn.fhir.jpa.cache.ResourcePersistentIdMap; 042import ca.uhn.fhir.jpa.dao.tx.HapiTransactionService; 043import ca.uhn.fhir.jpa.dao.tx.IHapiTransactionService; 044import ca.uhn.fhir.jpa.delete.DeleteConflictUtil; 045import ca.uhn.fhir.jpa.model.config.PartitionSettings; 046import ca.uhn.fhir.jpa.model.cross.IBasePersistedResource; 047import ca.uhn.fhir.jpa.model.entity.ResourceTable; 048import ca.uhn.fhir.jpa.model.entity.StorageSettings; 049import ca.uhn.fhir.jpa.model.search.StorageProcessingMessage; 050import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc; 051import ca.uhn.fhir.jpa.searchparam.extractor.ResourceIndexedSearchParams; 052import ca.uhn.fhir.jpa.searchparam.matcher.InMemoryMatchResult; 053import ca.uhn.fhir.jpa.searchparam.matcher.InMemoryResourceMatcher; 054import ca.uhn.fhir.jpa.searchparam.matcher.SearchParamMatcher; 055import ca.uhn.fhir.jpa.util.TransactionSemanticsHeader; 056import ca.uhn.fhir.model.api.ResourceMetadataKeyEnum; 057import ca.uhn.fhir.model.valueset.BundleEntryTransactionMethodEnum; 058import ca.uhn.fhir.parser.DataFormatException; 059import ca.uhn.fhir.parser.IParser; 060import ca.uhn.fhir.rest.api.Constants; 061import ca.uhn.fhir.rest.api.PatchTypeEnum; 062import ca.uhn.fhir.rest.api.PreferReturnEnum; 063import ca.uhn.fhir.rest.api.RestOperationTypeEnum; 064import ca.uhn.fhir.rest.api.server.RequestDetails; 065import ca.uhn.fhir.rest.api.server.SystemRequestDetails; 066import ca.uhn.fhir.rest.api.server.storage.DeferredInterceptorBroadcasts; 067import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId; 068import ca.uhn.fhir.rest.api.server.storage.TransactionDetails; 069import ca.uhn.fhir.rest.param.ParameterUtil; 070import ca.uhn.fhir.rest.server.RestfulServerUtils; 071import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException; 072import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; 073import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; 074import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException; 075import ca.uhn.fhir.rest.server.exceptions.NotModifiedException; 076import ca.uhn.fhir.rest.server.exceptions.PayloadTooLargeException; 077import ca.uhn.fhir.rest.server.exceptions.PreconditionFailedException; 078import ca.uhn.fhir.rest.server.method.BaseMethodBinding; 079import ca.uhn.fhir.rest.server.method.BaseResourceReturningMethodBinding; 080import ca.uhn.fhir.rest.server.method.UpdateMethodBinding; 081import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; 082import ca.uhn.fhir.rest.server.servlet.ServletSubRequestDetails; 083import ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster; 084import ca.uhn.fhir.rest.server.util.ServletRequestUtil; 085import ca.uhn.fhir.util.AsyncUtil; 086import ca.uhn.fhir.util.BundleUtil; 087import ca.uhn.fhir.util.ElementUtil; 088import ca.uhn.fhir.util.FhirTerser; 089import ca.uhn.fhir.util.ResourceReferenceInfo; 090import ca.uhn.fhir.util.StopWatch; 091import ca.uhn.fhir.util.UrlUtil; 092import com.google.common.annotations.VisibleForTesting; 093import com.google.common.collect.ArrayListMultimap; 094import com.google.common.collect.ListMultimap; 095import jakarta.annotation.Nonnull; 096import jakarta.annotation.Nullable; 097import org.apache.commons.lang3.RandomUtils; 098import org.apache.commons.lang3.StringUtils; 099import org.apache.commons.lang3.ThreadUtils; 100import org.apache.commons.lang3.Validate; 101import org.hl7.fhir.dstu3.model.Bundle; 102import org.hl7.fhir.exceptions.FHIRException; 103import org.hl7.fhir.instance.model.api.IBase; 104import org.hl7.fhir.instance.model.api.IBaseBinary; 105import org.hl7.fhir.instance.model.api.IBaseBundle; 106import org.hl7.fhir.instance.model.api.IBaseExtension; 107import org.hl7.fhir.instance.model.api.IBaseOperationOutcome; 108import org.hl7.fhir.instance.model.api.IBaseParameters; 109import org.hl7.fhir.instance.model.api.IBaseReference; 110import org.hl7.fhir.instance.model.api.IBaseResource; 111import org.hl7.fhir.instance.model.api.IIdType; 112import org.hl7.fhir.instance.model.api.IPrimitiveType; 113import org.hl7.fhir.r4.model.IdType; 114import org.slf4j.Logger; 115import org.slf4j.LoggerFactory; 116import org.springframework.beans.factory.annotation.Autowired; 117import org.springframework.core.task.TaskExecutor; 118import org.springframework.transaction.PlatformTransactionManager; 119import org.springframework.transaction.TransactionDefinition; 120import org.springframework.transaction.support.TransactionCallback; 121import org.springframework.transaction.support.TransactionTemplate; 122 123import java.time.Duration; 124import java.util.ArrayList; 125import java.util.Collection; 126import java.util.Collections; 127import java.util.Comparator; 128import java.util.Date; 129import java.util.HashMap; 130import java.util.HashSet; 131import java.util.IdentityHashMap; 132import java.util.Iterator; 133import java.util.LinkedHashMap; 134import java.util.LinkedHashSet; 135import java.util.List; 136import java.util.Map; 137import java.util.Optional; 138import java.util.Set; 139import java.util.TreeSet; 140import java.util.concurrent.ConcurrentHashMap; 141import java.util.concurrent.CountDownLatch; 142import java.util.concurrent.TimeUnit; 143import java.util.regex.Pattern; 144import java.util.stream.Collectors; 145 146import static ca.uhn.fhir.util.HapiExtensions.EXTENSION_TRANSACTION_ENTRY_PARTITION_IDS; 147import static ca.uhn.fhir.util.StringUtil.toUtf8String; 148import static java.util.Objects.isNull; 149import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; 150import static org.apache.commons.lang3.ObjectUtils.getIfNull; 151import static org.apache.commons.lang3.StringUtils.defaultString; 152import static org.apache.commons.lang3.StringUtils.isBlank; 153import static org.apache.commons.lang3.StringUtils.isNotBlank; 154 155public abstract class BaseTransactionProcessor { 156 157 public static final String URN_PREFIX = "urn:"; 158 public static final String URN_PREFIX_ESCAPED = UrlUtil.escapeUrlParam(URN_PREFIX); 159 public static final Pattern UNQUALIFIED_MATCH_URL_START = Pattern.compile("^[a-zA-Z0-9_-]+="); 160 public static final Pattern INVALID_PLACEHOLDER_PATTERN = Pattern.compile("[a-zA-Z]+:.*"); 161 162 private static final Logger ourLog = LoggerFactory.getLogger(BaseTransactionProcessor.class); 163 private static final String POST = "POST"; 164 private static final String PUT = "PUT"; 165 private static final String DELETE = "DELETE"; 166 private static final String PATCH = "PATCH"; 167 168 @Autowired 169 private IRequestPartitionHelperSvc myRequestPartitionHelperService; 170 171 @Autowired 172 private PlatformTransactionManager myTxManager; 173 174 @Autowired 175 private FhirContext myContext; 176 177 @Autowired 178 @SuppressWarnings("rawtypes") 179 private ITransactionProcessorVersionAdapter myVersionAdapter; 180 181 @Autowired 182 private DaoRegistry myDaoRegistry; 183 184 @Autowired 185 private IInterceptorBroadcaster myInterceptorBroadcaster; 186 187 @Autowired 188 protected IHapiTransactionService myHapiTransactionService; 189 190 @Autowired 191 private StorageSettings myStorageSettings; 192 193 @Autowired 194 PartitionSettings myPartitionSettings; 195 196 @Autowired 197 private InMemoryResourceMatcher myInMemoryResourceMatcher; 198 199 @Autowired 200 private SearchParamMatcher mySearchParamMatcher; 201 202 @Autowired 203 private ThreadPoolFactory myThreadPoolFactory; 204 205 private TaskExecutor myExecutor; 206 207 @Autowired 208 private IResourceVersionSvc myResourceVersionSvc; 209 210 @VisibleForTesting 211 public void setStorageSettings(StorageSettings theStorageSettings) { 212 myStorageSettings = theStorageSettings; 213 } 214 215 public ITransactionProcessorVersionAdapter getVersionAdapter() { 216 return myVersionAdapter; 217 } 218 219 @VisibleForTesting 220 public void setVersionAdapter(ITransactionProcessorVersionAdapter theVersionAdapter) { 221 myVersionAdapter = theVersionAdapter; 222 } 223 224 private TaskExecutor getTaskExecutor() { 225 if (myExecutor == null) { 226 myExecutor = myThreadPoolFactory.newThreadPool( 227 myStorageSettings.getBundleBatchPoolSize(), 228 myStorageSettings.getBundleBatchMaxPoolSize(), 229 "bundle-batch-"); 230 } 231 return myExecutor; 232 } 233 234 public <BUNDLE extends IBaseBundle> BUNDLE transaction( 235 RequestDetails theRequestDetails, BUNDLE theRequest, boolean theNestedMode) { 236 String actionName = "Transaction"; 237 238 IInterceptorBroadcaster compositeBroadcaster = 239 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, theRequestDetails); 240 241 // Interceptor call: STORAGE_TRANSACTION_PROCESSING 242 if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_TRANSACTION_PROCESSING)) { 243 HookParams params = new HookParams() 244 .add(RequestDetails.class, theRequestDetails) 245 .addIfMatchesType(ServletRequestDetails.class, theRequest) 246 .add(IBaseBundle.class, theRequest); 247 compositeBroadcaster.callHooks(Pointcut.STORAGE_TRANSACTION_PROCESSING, params); 248 } 249 250 IBaseBundle response; 251 // Interceptor call: STORAGE_TRANSACTION_PRE_PARTITION 252 if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_TRANSACTION_PRE_PARTITION)) { 253 response = new TransactionPartitionProcessor<BUNDLE>( 254 this, myContext, theRequestDetails, theNestedMode, compositeBroadcaster, actionName) 255 .execute(theRequest); 256 } else { 257 response = processTransactionAsSubRequest(theRequestDetails, theRequest, actionName, theNestedMode); 258 } 259 260 List<IBase> entries = myVersionAdapter.getEntries(response); 261 for (int i = 0; i < entries.size(); i++) { 262 if (ElementUtil.isEmpty(entries.get(i))) { 263 entries.remove(i); 264 i--; 265 } 266 } 267 268 return (BUNDLE) response; 269 } 270 271 public IBaseBundle collection(final RequestDetails theRequestDetails, IBaseBundle theRequest) { 272 String transactionType = myVersionAdapter.getBundleType(theRequest); 273 274 if (!org.hl7.fhir.r4.model.Bundle.BundleType.COLLECTION.toCode().equals(transactionType)) { 275 throw new InvalidRequestException( 276 Msg.code(526) + "Can not process collection Bundle of type: " + transactionType); 277 } 278 279 ourLog.info( 280 "Beginning storing collection with {} resources", 281 myVersionAdapter.getEntries(theRequest).size()); 282 283 TransactionTemplate txTemplate = new TransactionTemplate(myTxManager); 284 txTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); 285 286 IBaseBundle resp = 287 myVersionAdapter.createBundle(org.hl7.fhir.r4.model.Bundle.BundleType.BATCHRESPONSE.toCode()); 288 289 List<IBaseResource> resources = new ArrayList<>(); 290 for (final Object nextRequestEntry : myVersionAdapter.getEntries(theRequest)) { 291 IBaseResource resource = myVersionAdapter.getResource((IBase) nextRequestEntry); 292 resources.add(resource); 293 } 294 295 IBaseBundle transactionBundle = myVersionAdapter.createBundle("transaction"); 296 for (IBaseResource next : resources) { 297 IBase entry = myVersionAdapter.addEntry(transactionBundle); 298 myVersionAdapter.setResource(entry, next); 299 myVersionAdapter.setRequestVerb(entry, "PUT"); 300 myVersionAdapter.setRequestUrl( 301 entry, next.getIdElement().toUnqualifiedVersionless().getValue()); 302 } 303 304 transaction(theRequestDetails, transactionBundle, false); 305 306 return resp; 307 } 308 309 @SuppressWarnings("unchecked") 310 private void populateEntryWithOperationOutcome(BaseServerResponseException caughtEx, IBase nextEntry) { 311 myVersionAdapter.populateEntryWithOperationOutcome(caughtEx, nextEntry); 312 } 313 314 @SuppressWarnings("unchecked") 315 private void handleTransactionCreateOrUpdateOutcome( 316 IdSubstitutionMap theIdSubstitutions, 317 Map<IIdType, DaoMethodOutcome> theIdToPersistedOutcome, 318 IIdType theNextResourceId, 319 DaoMethodOutcome theOutcome, 320 IBase theNewEntry, 321 String theResourceType, 322 IBaseResource theRes, 323 RequestDetails theRequestDetails) { 324 IIdType newId = theOutcome.getId().toUnqualified(); 325 IIdType resourceId = 326 isPlaceholder(theNextResourceId) ? theNextResourceId : theNextResourceId.toUnqualifiedVersionless(); 327 if (!newId.equals(resourceId)) { 328 if (!theNextResourceId.isEmpty()) { 329 theIdSubstitutions.put(resourceId, newId); 330 } 331 if (isPlaceholder(resourceId)) { 332 /* 333 * The correct way for substitution IDs to be is to be with no resource type, but we'll accept the qualified kind too just to be lenient. 334 */ 335 IIdType id = myContext.getVersion().newIdType(); 336 id.setValue(theResourceType + '/' + resourceId.getValue()); 337 theIdSubstitutions.put(id, newId); 338 } 339 } 340 341 populateIdToPersistedOutcomeMap(theIdToPersistedOutcome, newId, theOutcome); 342 343 if (shouldSwapBinaryToActualResource(theRes, theResourceType, theNextResourceId)) { 344 theRes = theIdToPersistedOutcome.get(newId).getResource(); 345 } 346 347 if (theOutcome.getCreated()) { 348 myVersionAdapter.setResponseStatus(theNewEntry, toStatusString(Constants.STATUS_HTTP_201_CREATED)); 349 } else { 350 myVersionAdapter.setResponseStatus(theNewEntry, toStatusString(Constants.STATUS_HTTP_200_OK)); 351 } 352 353 Date lastModified = getLastModified(theRes); 354 myVersionAdapter.setResponseLastModified(theNewEntry, lastModified); 355 356 if (theOutcome.getOperationOutcome() != null) { 357 myVersionAdapter.setResponseOutcome(theNewEntry, theOutcome.getOperationOutcome()); 358 } 359 360 if (theRequestDetails != null) { 361 String prefer = theRequestDetails.getHeader(Constants.HEADER_PREFER); 362 PreferReturnEnum preferReturn = 363 RestfulServerUtils.parsePreferHeader(null, prefer).getReturn(); 364 if (preferReturn != null) { 365 if (preferReturn == PreferReturnEnum.REPRESENTATION) { 366 if (theOutcome.getResource() != null) { 367 theOutcome.fireResourceViewCallbacks(); 368 myVersionAdapter.setResource(theNewEntry, theOutcome.getResource()); 369 } 370 } 371 } 372 } 373 } 374 375 /** 376 * Method which populates entry in idToPersistedOutcome. 377 * Will store whatever outcome is sent, unless the key already exists, then we only replace an instance if we find that the instance 378 * we are replacing with is non-lazy. This allows us to evaluate later more easily, as we _know_ we need access to these. 379 */ 380 private void populateIdToPersistedOutcomeMap( 381 Map<IIdType, DaoMethodOutcome> idToPersistedOutcome, IIdType newId, DaoMethodOutcome outcome) { 382 // Prefer real method outcomes over lazy ones. 383 if (idToPersistedOutcome.containsKey(newId)) { 384 if (!(outcome instanceof LazyDaoMethodOutcome)) { 385 idToPersistedOutcome.put(newId, outcome); 386 } 387 } else { 388 idToPersistedOutcome.put(newId, outcome); 389 } 390 } 391 392 private Date getLastModified(IBaseResource theRes) { 393 return theRes.getMeta().getLastUpdated(); 394 } 395 396 IBaseBundle processTransactionAsSubRequest( 397 RequestDetails theRequestDetails, IBaseBundle theRequest, String theActionName, boolean theNestedMode) { 398 return processTransactionAsSubRequest( 399 theRequestDetails, new TransactionDetails(), theRequest, theActionName, theNestedMode); 400 } 401 402 IBaseBundle processTransactionAsSubRequest( 403 RequestDetails theRequestDetails, 404 TransactionDetails theTransactionDetails, 405 IBaseBundle theRequest, 406 String theActionName, 407 boolean theNestedMode) { 408 BaseStorageDao.markRequestAsProcessingSubRequest(theRequestDetails); 409 try { 410 return processTransaction( 411 theRequestDetails, theTransactionDetails, theRequest, theActionName, theNestedMode); 412 } finally { 413 BaseStorageDao.clearRequestAsProcessingSubRequest(theRequestDetails); 414 } 415 } 416 417 @VisibleForTesting 418 public void setTxManager(PlatformTransactionManager theTxManager) { 419 myTxManager = theTxManager; 420 } 421 422 private IBaseBundle batch(final RequestDetails theRequestDetails, IBaseBundle theRequest, boolean theNestedMode) { 423 TransactionSemanticsHeader transactionSemantics = TransactionSemanticsHeader.DEFAULT; 424 if (theRequestDetails != null) { 425 String transactionSemanticsString = theRequestDetails.getHeader("X-Transaction-Semantics"); 426 if (transactionSemanticsString != null) { 427 transactionSemantics = TransactionSemanticsHeader.parse(transactionSemanticsString); 428 } 429 } 430 431 int totalAttempts = defaultIfNull(transactionSemantics.getRetryCount(), 0) + 1; 432 boolean switchedToBatch = false; 433 434 int minRetryDelay = getIfNull(transactionSemantics.getMinRetryDelay(), 0); 435 int maxRetryDelay = getIfNull(transactionSemantics.getMaxRetryDelay(), 0); 436 437 // Don't let the user request a crazy delay, this could be used 438 // as a DOS attack 439 maxRetryDelay = Math.max(maxRetryDelay, minRetryDelay); 440 maxRetryDelay = Math.min(maxRetryDelay, 10000); 441 totalAttempts = Math.min(totalAttempts, 5); 442 443 IBaseBundle response; 444 for (int i = 1; ; i++) { 445 try { 446 if (i < totalAttempts && transactionSemantics.isTryBatchAsTransactionFirst()) { 447 BundleUtil.setBundleType(myContext, theRequest, "transaction"); 448 response = processTransaction( 449 theRequestDetails, new TransactionDetails(), theRequest, "Transaction", theNestedMode); 450 } else { 451 BundleUtil.setBundleType(myContext, theRequest, "batch"); 452 response = processBatch(theRequestDetails, theRequest, theNestedMode); 453 } 454 break; 455 } catch (BaseServerResponseException e) { 456 if (i >= totalAttempts || theNestedMode) { 457 throw e; 458 } 459 460 long delay = RandomUtils.insecure().randomLong(minRetryDelay, maxRetryDelay); 461 String sleepMessage = ""; 462 if (delay > 0) { 463 sleepMessage = "Sleeping for " + delay + " ms. "; 464 } 465 466 String switchedToBatchMessage = ""; 467 if (switchedToBatch) { 468 switchedToBatchMessage = " (as batch)"; 469 } 470 471 String switchingToBatchMessage = ""; 472 if (i + 1 == totalAttempts && transactionSemantics.isTryBatchAsTransactionFirst()) { 473 switchingToBatchMessage = "Performing final retry using batch semantics. "; 474 switchedToBatch = true; 475 BundleUtil.setBundleType(myContext, theRequest, "batch"); 476 } 477 478 ourLog.warn( 479 "Transaction processing attempt{} {}/{} failed. {}{}", 480 switchedToBatchMessage, 481 i, 482 totalAttempts, 483 sleepMessage, 484 switchingToBatchMessage); 485 486 if (delay > 0) { 487 ThreadUtils.sleepQuietly(Duration.ofMillis(delay)); 488 } 489 } 490 } 491 492 return response; 493 } 494 495 private IBaseBundle processBatch(RequestDetails theRequestDetails, IBaseBundle theRequest, boolean theNestedMode) { 496 ourLog.info( 497 "Beginning batch with {} resources", 498 myVersionAdapter.getEntries(theRequest).size()); 499 500 long start = System.currentTimeMillis(); 501 502 IBaseBundle response = 503 myVersionAdapter.createBundle(org.hl7.fhir.r4.model.Bundle.BundleType.BATCHRESPONSE.toCode()); 504 Map<Integer, Object> responseMap = new ConcurrentHashMap<>(); 505 506 List<IBase> requestEntries = myVersionAdapter.getEntries(theRequest); 507 int requestEntriesSize = requestEntries.size(); 508 509 // Now, run all non-gets sequentially, and all gets are submitted to the executor to run (potentially) in 510 // parallel 511 // The result is kept in the map to save the original position 512 List<RetriableBundleTask> getCalls = new ArrayList<>(); 513 List<RetriableBundleTask> nonGetCalls = new ArrayList<>(); 514 515 CountDownLatch completionLatch = new CountDownLatch(requestEntriesSize); 516 for (int i = 0; i < requestEntriesSize; i++) { 517 IBase nextRequestEntry = requestEntries.get(i); 518 RetriableBundleTask retriableBundleTask = new RetriableBundleTask( 519 completionLatch, theRequestDetails, responseMap, i, nextRequestEntry, theNestedMode); 520 if (myVersionAdapter 521 .getEntryRequestVerb(myContext, nextRequestEntry) 522 .equalsIgnoreCase("GET")) { 523 getCalls.add(retriableBundleTask); 524 } else { 525 nonGetCalls.add(retriableBundleTask); 526 } 527 } 528 // Execute all non-gets on calling thread. 529 nonGetCalls.forEach(RetriableBundleTask::run); 530 // Execute all gets (potentially in a pool) 531 if (myStorageSettings.getBundleBatchPoolSize() == 1) { 532 getCalls.forEach(RetriableBundleTask::run); 533 } else { 534 getCalls.forEach(getCall -> getTaskExecutor().execute(getCall)); 535 } 536 537 // waiting for all async tasks to be completed 538 AsyncUtil.awaitLatchAndIgnoreInterrupt(completionLatch, 300L, TimeUnit.SECONDS); 539 540 // Now, create the bundle response in original order 541 Object nextResponseEntry; 542 for (int i = 0; i < requestEntriesSize; i++) { 543 544 nextResponseEntry = responseMap.get(i); 545 if (nextResponseEntry instanceof ServerResponseExceptionHolder) { 546 ServerResponseExceptionHolder caughtEx = (ServerResponseExceptionHolder) nextResponseEntry; 547 if (caughtEx.getException() != null) { 548 IBase nextEntry = myVersionAdapter.addEntry(response); 549 populateEntryWithOperationOutcome(caughtEx.getException(), nextEntry); 550 myVersionAdapter.setResponseStatus( 551 nextEntry, toStatusString(caughtEx.getException().getStatusCode())); 552 } 553 } else { 554 myVersionAdapter.addEntry(response, (IBase) nextResponseEntry); 555 } 556 } 557 558 long delay = System.currentTimeMillis() - start; 559 ourLog.info("Batch completed in {}ms", delay); 560 561 return response; 562 } 563 564 @VisibleForTesting 565 public void setHapiTransactionService(HapiTransactionService theHapiTransactionService) { 566 myHapiTransactionService = theHapiTransactionService; 567 } 568 569 private IBaseBundle processTransaction( 570 final RequestDetails theRequestDetails, 571 final TransactionDetails theTransactionDetails, 572 final IBaseBundle theRequest, 573 final String theActionName, 574 boolean theNestedMode) { 575 validateDependencies(); 576 577 String transactionType = myVersionAdapter.getBundleType(theRequest); 578 579 if (org.hl7.fhir.r4.model.Bundle.BundleType.BATCH.toCode().equals(transactionType)) { 580 return batch(theRequestDetails, theRequest, theNestedMode); 581 } 582 583 if (transactionType == null) { 584 String message = "Transaction Bundle did not specify valid Bundle.type, assuming " 585 + Bundle.BundleType.TRANSACTION.toCode(); 586 ourLog.warn(message); 587 transactionType = org.hl7.fhir.r4.model.Bundle.BundleType.TRANSACTION.toCode(); 588 } 589 if (!org.hl7.fhir.r4.model.Bundle.BundleType.TRANSACTION.toCode().equals(transactionType)) { 590 throw new InvalidRequestException( 591 Msg.code(527) + "Unable to process transaction where incoming Bundle.type = " + transactionType); 592 } 593 594 // Make a copy of the list because it gets sorted below, and we don't want to 595 // modify the original Bundle 596 List<IBase> requestEntries = new ArrayList<>(myVersionAdapter.getEntries(theRequest)); 597 int numberOfEntries = requestEntries.size(); 598 599 if (myStorageSettings.getMaximumTransactionBundleSize() != null 600 && numberOfEntries > myStorageSettings.getMaximumTransactionBundleSize()) { 601 throw new PayloadTooLargeException( 602 Msg.code(528) + "Transaction Bundle Too large. Transaction bundle contains " + numberOfEntries 603 + " which exceedes the maximum permitted transaction bundle size of " 604 + myStorageSettings.getMaximumTransactionBundleSize()); 605 } 606 607 ourLog.debug("Beginning {} with {} resources", theActionName, numberOfEntries); 608 609 theTransactionDetails.setFhirTransaction(true); 610 final StopWatch transactionStopWatch = new StopWatch(); 611 612 // Do all entries have a verb? 613 for (int i = 0; i < numberOfEntries; i++) { 614 IBase nextReqEntry = requestEntries.get(i); 615 String verb = myVersionAdapter.getEntryRequestVerb(myContext, nextReqEntry); 616 if (verb == null || !isValidVerb(verb)) { 617 throw new InvalidRequestException(Msg.code(529) 618 + myContext 619 .getLocalizer() 620 .getMessage(BaseStorageDao.class, "transactionEntryHasInvalidVerb", verb, i)); 621 } 622 } 623 624 /* 625 * We want to execute the transaction request bundle elements in the order 626 * specified by the FHIR specification (see TransactionSorter) so we save the 627 * original order in the request, then sort it. 628 * 629 * Entries with a type of GET are removed from the bundle so that they 630 * can be processed at the very end. We do this because the incoming resources 631 * are saved in a two-phase way in order to deal with interdependencies, and 632 * we want the GET processing to use the final indexing state 633 */ 634 final IBaseBundle response = 635 myVersionAdapter.createBundle(org.hl7.fhir.r4.model.Bundle.BundleType.TRANSACTIONRESPONSE.toCode()); 636 List<IBase> getEntries = new ArrayList<>(); 637 final IdentityHashMap<IBase, Integer> originalRequestOrder = new IdentityHashMap<>(); 638 for (int i = 0; i < requestEntries.size(); i++) { 639 IBase requestEntry = requestEntries.get(i); 640 641 originalRequestOrder.put(requestEntry, i); 642 myVersionAdapter.addEntry(response); 643 if (myVersionAdapter.getEntryRequestVerb(myContext, requestEntry).equals("GET")) { 644 getEntries.add(requestEntry); 645 } 646 } 647 648 /* 649 * See FhirSystemDaoDstu3Test#testTransactionWithPlaceholderIdInMatchUrl 650 * Basically if the resource has a match URL that references a placeholder, 651 * we try to handle the resource with the placeholder first. 652 */ 653 Set<String> placeholderIds = new HashSet<>(); 654 for (IBase nextEntry : requestEntries) { 655 String fullUrl = myVersionAdapter.getFullUrl(nextEntry); 656 if (isNotBlank(fullUrl) && fullUrl.startsWith(URN_PREFIX)) { 657 placeholderIds.add(fullUrl); 658 } 659 } 660 requestEntries.sort(new TransactionSorter(placeholderIds)); 661 662 // perform all writes 663 prepareThenExecuteTransactionWriteOperations( 664 theRequestDetails, 665 theActionName, 666 theTransactionDetails, 667 transactionStopWatch, 668 response, 669 originalRequestOrder, 670 requestEntries); 671 672 // perform all gets 673 // (we do these last so that the gets happen on the final state of the DB; 674 // see above note) 675 doTransactionReadOperations( 676 theRequestDetails, response, getEntries, originalRequestOrder, transactionStopWatch, theNestedMode); 677 678 // Interceptor broadcast: JPA_PERFTRACE_INFO 679 IInterceptorBroadcaster compositeBroadcaster = 680 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, theRequestDetails); 681 if (compositeBroadcaster.hasHooks(Pointcut.JPA_PERFTRACE_INFO)) { 682 String taskDurations = transactionStopWatch.formatTaskDurations(); 683 StorageProcessingMessage message = new StorageProcessingMessage(); 684 message.setMessage("Transaction timing:\n" + taskDurations); 685 HookParams params = new HookParams() 686 .add(RequestDetails.class, theRequestDetails) 687 .addIfMatchesType(ServletRequestDetails.class, theRequestDetails) 688 .add(StorageProcessingMessage.class, message); 689 compositeBroadcaster.callHooks(Pointcut.JPA_PERFTRACE_INFO, params); 690 } 691 692 return response; 693 } 694 695 @SuppressWarnings("unchecked") 696 private void doTransactionReadOperations( 697 final RequestDetails theRequestDetails, 698 IBaseBundle theResponse, 699 List<IBase> theGetEntries, 700 IdentityHashMap<IBase, Integer> theOriginalRequestOrder, 701 StopWatch theTransactionStopWatch, 702 boolean theNestedMode) { 703 if (theGetEntries.size() > 0) { 704 theTransactionStopWatch.startTask("Process " + theGetEntries.size() + " GET entries"); 705 706 /* 707 * Loop through the request and process any entries of type GET 708 */ 709 for (IBase nextReqEntry : theGetEntries) { 710 if (theNestedMode) { 711 throw new InvalidRequestException( 712 Msg.code(530) + "Can not invoke read operation on nested transaction"); 713 } 714 715 if (!(theRequestDetails instanceof ServletRequestDetails)) { 716 throw new MethodNotAllowedException( 717 Msg.code(531) + "Can not call transaction GET methods from this context"); 718 } 719 720 ServletRequestDetails srd = (ServletRequestDetails) theRequestDetails; 721 Integer originalOrder = theOriginalRequestOrder.get(nextReqEntry); 722 IBase nextRespEntry = 723 (IBase) myVersionAdapter.getEntries(theResponse).get(originalOrder); 724 725 ArrayListMultimap<String, String> paramValues = ArrayListMultimap.create(); 726 ServletSubRequestDetails requestDetailsForEntry = 727 createRequestDetailsForReadEntry(srd, nextReqEntry, paramValues); 728 729 String url = requestDetailsForEntry.getRequestPath(); 730 731 BaseMethodBinding method = srd.getServer().determineResourceMethod(requestDetailsForEntry, url); 732 if (method == null) { 733 throw new IllegalArgumentException(Msg.code(532) + "Unable to handle GET " + url); 734 } 735 736 Validate.isTrue(method instanceof BaseResourceReturningMethodBinding, "Unable to handle GET {}", url); 737 try { 738 BaseResourceReturningMethodBinding methodBinding = (BaseResourceReturningMethodBinding) method; 739 requestDetailsForEntry.setRestOperationType(methodBinding.getRestOperationType()); 740 741 IBaseResource resource = methodBinding.doInvokeServer(srd.getServer(), requestDetailsForEntry); 742 if (paramValues.containsKey(Constants.PARAM_SUMMARY) 743 || paramValues.containsKey(Constants.PARAM_CONTENT)) { 744 resource = filterNestedBundle(requestDetailsForEntry, resource); 745 } 746 myVersionAdapter.setResource(nextRespEntry, resource); 747 myVersionAdapter.setResponseStatus(nextRespEntry, toStatusString(Constants.STATUS_HTTP_200_OK)); 748 } catch (NotModifiedException e) { 749 myVersionAdapter.setResponseStatus( 750 nextRespEntry, toStatusString(Constants.STATUS_HTTP_304_NOT_MODIFIED)); 751 } catch (BaseServerResponseException e) { 752 ourLog.info("Failure processing transaction GET {}: {}", url, e.toString()); 753 myVersionAdapter.setResponseStatus(nextRespEntry, toStatusString(e.getStatusCode())); 754 populateEntryWithOperationOutcome(e, nextRespEntry); 755 } 756 } 757 theTransactionStopWatch.endCurrentTask(); 758 } 759 } 760 761 /** 762 * Checks if the given request entry has the extension specified to override the partition ids to use when processing that entry. 763 * If the extension is present, this method will set the partition ids header in the given request details. 764 */ 765 private void setRequestPartitionHeaderIfEntryHasTheExtension(IBase theReqEntry, RequestDetails theRequestDetails) { 766 Optional<IBaseExtension<?, ?>> partitionIdsExtensionOptional = 767 myVersionAdapter.getEntryRequestExtensionByUrl(theReqEntry, EXTENSION_TRANSACTION_ENTRY_PARTITION_IDS); 768 if (partitionIdsExtensionOptional.isPresent() 769 && partitionIdsExtensionOptional.get().getValue() instanceof IPrimitiveType<?>) { 770 IPrimitiveType<?> valueAsPrimitiveType = 771 (IPrimitiveType<?>) partitionIdsExtensionOptional.get().getValue(); 772 String value = valueAsPrimitiveType.getValueAsString(); 773 theRequestDetails.setHeaders(Constants.HEADER_X_REQUEST_PARTITION_IDS, List.of(value)); 774 } 775 } 776 777 /** 778 * Creates a new RequestDetails object based on the given RequestDetails. The new RequestDetails is to be used 779 * when processing a write entry. 780 * If the entry.request has the extension to override the partition ids, this method 781 * sets the partition ids header in the newly created request details with the values from extension. 782 * This allows using different partitions for different entries in the same transaction. 783 * 784 * @return the newly created request details 785 */ 786 private RequestDetails createRequestDetailsForWriteEntry( 787 RequestDetails theRequestDetails, IBase theEntry, String theUrl, String theVerb) { 788 789 if (theRequestDetails == null) { 790 ourLog.warn( 791 "The RequestDetails passed in to the transaction is null. Cannot create a new RequestDetails for transaction entry."); 792 return null; 793 } 794 795 RequestDetails newRequestDetails; 796 if (theRequestDetails instanceof ServletRequestDetails) { 797 newRequestDetails = ServletRequestUtil.getServletSubRequestDetails( 798 (ServletRequestDetails) theRequestDetails, theUrl, theVerb, ArrayListMultimap.create()); 799 } else if (theRequestDetails instanceof SystemRequestDetails) { 800 newRequestDetails = new SystemRequestDetails((SystemRequestDetails) theRequestDetails); 801 } else { 802 // RequestDetails is not a ServletRequestDetails or SystemRequestDetails, and I don't know how to properly 803 // clone such a RequestDetails. Use the original RequestDetails without making any entry specific 804 // modifications 805 ourLog.warn( 806 "Cannot create a new RequestDetails for transaction entry out of the existing RequestDetails which is of type '{}'" 807 + "Using the original RequestDetails for transaction entries without any entry specific modifications.", 808 theRequestDetails.getClass().getSimpleName()); 809 return theRequestDetails; 810 } 811 setRequestPartitionHeaderIfEntryHasTheExtension(theEntry, newRequestDetails); 812 return newRequestDetails; 813 } 814 815 /** 816 * Creates a new RequestDetails based on the given one. The returned RequestDetails is to be used when processing 817 * a GET entry of transaction. 818 * It sets the headers in the newly request details according to the information from entry.request if needed. 819 * Currently, GET entries only support ServletRequestDetails so it handles only that type. 820 */ 821 private ServletSubRequestDetails createRequestDetailsForReadEntry( 822 ServletRequestDetails theRequestDetails, IBase theEntry, ArrayListMultimap<String, String> theParamValues) { 823 824 final String verb = "GET"; 825 String transactionUrl = extractTransactionUrlOrThrowException(theEntry, verb); 826 827 ServletSubRequestDetails subRequestDetails = 828 ServletRequestUtil.getServletSubRequestDetails(theRequestDetails, transactionUrl, verb, theParamValues); 829 830 if (isNotBlank(myVersionAdapter.getEntryRequestIfMatch(theEntry))) { 831 subRequestDetails.addHeader(Constants.HEADER_IF_MATCH, myVersionAdapter.getEntryRequestIfMatch(theEntry)); 832 } 833 if (isNotBlank(myVersionAdapter.getEntryRequestIfNoneExist(theEntry))) { 834 subRequestDetails.addHeader( 835 Constants.HEADER_IF_NONE_EXIST, myVersionAdapter.getEntryRequestIfNoneExist(theEntry)); 836 } 837 if (isNotBlank(myVersionAdapter.getEntryRequestIfNoneMatch(theEntry))) { 838 subRequestDetails.addHeader( 839 Constants.HEADER_IF_NONE_MATCH, myVersionAdapter.getEntryRequestIfNoneMatch(theEntry)); 840 } 841 842 setRequestPartitionHeaderIfEntryHasTheExtension(theEntry, subRequestDetails); 843 844 return subRequestDetails; 845 } 846 847 /** 848 * All of the write operations in the transaction (PUT, POST, etc.. basically anything 849 * except GET) are performed in their own database transaction before we do the reads. 850 * We do this because the reads (specifically the searches) often spawn their own 851 * secondary database transaction and if we allow that within the primary 852 * database transaction we can end up with deadlocks if the server is under 853 * heavy load with lots of concurrent transactions using all available 854 * database connections. 855 */ 856 @SuppressWarnings("unchecked") 857 private void prepareThenExecuteTransactionWriteOperations( 858 RequestDetails theRequestDetails, 859 String theActionName, 860 TransactionDetails theTransactionDetails, 861 StopWatch theTransactionStopWatch, 862 IBaseBundle theResponse, 863 IdentityHashMap<IBase, Integer> theOriginalRequestOrder, 864 List<IBase> theEntries) { 865 866 TransactionWriteOperationsDetails writeOperationsDetails = null; 867 if (haveWriteOperationsHooks(theRequestDetails)) { 868 writeOperationsDetails = buildWriteOperationsDetails(theEntries); 869 callWriteOperationsHook( 870 Pointcut.STORAGE_TRANSACTION_WRITE_OPERATIONS_PRE, 871 theRequestDetails, 872 theTransactionDetails, 873 writeOperationsDetails); 874 } 875 876 TransactionCallback<EntriesToProcessMap> txCallback = status -> { 877 final Set<IIdType> allIds = new LinkedHashSet<>(); 878 final IdSubstitutionMap idSubstitutions = new IdSubstitutionMap(); 879 final Map<IIdType, DaoMethodOutcome> idToPersistedOutcome = new LinkedHashMap<>(); 880 881 EntriesToProcessMap retVal = doTransactionWriteOperations( 882 theRequestDetails, 883 theActionName, 884 theTransactionDetails, 885 allIds, 886 idSubstitutions, 887 idToPersistedOutcome, 888 theResponse, 889 theOriginalRequestOrder, 890 theEntries, 891 theTransactionStopWatch); 892 893 theTransactionStopWatch.startTask("Commit writes to database"); 894 return retVal; 895 }; 896 EntriesToProcessMap entriesToProcess; 897 898 RequestPartitionId requestPartitionId = 899 determineRequestPartitionIdForWriteEntries(theRequestDetails, theTransactionDetails, theEntries); 900 901 try { 902 entriesToProcess = myHapiTransactionService 903 .withRequest(theRequestDetails) 904 .withRequestPartitionId(requestPartitionId) 905 .withTransactionDetails(theTransactionDetails) 906 .execute(txCallback); 907 } finally { 908 if (haveWriteOperationsHooks(theRequestDetails)) { 909 callWriteOperationsHook( 910 Pointcut.STORAGE_TRANSACTION_WRITE_OPERATIONS_POST, 911 theRequestDetails, 912 theTransactionDetails, 913 writeOperationsDetails); 914 } 915 } 916 917 theTransactionStopWatch.endCurrentTask(); 918 919 for (Map.Entry<IBase, IIdType> nextEntry : entriesToProcess.entrySet()) { 920 String responseLocation = nextEntry.getValue().toUnqualified().getValue(); 921 String responseEtag = nextEntry.getValue().getVersionIdPart(); 922 myVersionAdapter.setResponseLocation(nextEntry.getKey(), responseLocation); 923 myVersionAdapter.setResponseETag(nextEntry.getKey(), responseEtag); 924 } 925 } 926 927 /** 928 * This method looks at the FHIR actions being performed in a List of bundle entries, 929 * and determines the associated request partitions. 930 */ 931 @Nullable 932 protected RequestPartitionId determineRequestPartitionIdForWriteEntries( 933 RequestDetails theRequestDetails, TransactionDetails theTransactionDetails, List<IBase> theEntries) { 934 if (!myPartitionSettings.isPartitioningEnabled()) { 935 return RequestPartitionId.allPartitions(); 936 } 937 938 return theEntries.stream() 939 .map(e -> getEntryRequestPartitionId(theRequestDetails, theTransactionDetails, e)) 940 .reduce(null, (accumulator, nextPartition) -> { 941 if (accumulator == null) { 942 return nextPartition; 943 } else if (nextPartition == null) { 944 return accumulator; 945 } else if (myHapiTransactionService.isCompatiblePartition(accumulator, nextPartition)) { 946 return accumulator.mergeIds(nextPartition); 947 } else if (!nextPartition.isAllPartitions()) { 948 String msg = myContext 949 .getLocalizer() 950 .getMessage( 951 BaseTransactionProcessor.class, "multiplePartitionAccesses", theEntries.size()); 952 throw new InvalidRequestException(Msg.code(2541) + msg); 953 } else { 954 return accumulator; 955 } 956 }); 957 } 958 959 @Nullable 960 private RequestPartitionId getEntryRequestPartitionId( 961 RequestDetails theRequestDetails, TransactionDetails theTransactionDetails, IBase theEntry) { 962 963 RequestPartitionId nextWriteEntryRequestPartitionId = null; 964 String verb = myVersionAdapter.getEntryRequestVerb(myContext, theEntry); 965 String url = extractTransactionUrlOrThrowException(theEntry, verb); 966 RequestDetails requestDetailsForEntry = 967 createRequestDetailsForWriteEntry(theRequestDetails, theEntry, url, verb); 968 if (isNotBlank(verb)) { 969 BundleEntryTransactionMethodEnum verbEnum = BundleEntryTransactionMethodEnum.valueOf(verb); 970 switch (verbEnum) { 971 case GET: 972 nextWriteEntryRequestPartitionId = null; 973 break; 974 case DELETE: { 975 String requestUrl = myVersionAdapter.getEntryRequestUrl(theEntry); 976 if (isNotBlank(requestUrl)) { 977 IdType id = new IdType(requestUrl); 978 String resourceType = id.getResourceType(); 979 ReadPartitionIdRequestDetails details = 980 ReadPartitionIdRequestDetails.forDelete(resourceType, id); 981 nextWriteEntryRequestPartitionId = 982 myRequestPartitionHelperService.determineReadPartitionForRequest( 983 requestDetailsForEntry, details); 984 } 985 break; 986 } 987 case PATCH: { 988 String requestUrl = myVersionAdapter.getEntryRequestUrl(theEntry); 989 if (isNotBlank(requestUrl)) { 990 IdType id = new IdType(requestUrl); 991 String resourceType = id.getResourceType(); 992 ReadPartitionIdRequestDetails details = 993 ReadPartitionIdRequestDetails.forPatch(resourceType, id); 994 nextWriteEntryRequestPartitionId = 995 myRequestPartitionHelperService.determineReadPartitionForRequest( 996 requestDetailsForEntry, details); 997 } 998 break; 999 } 1000 case POST: { 1001 IBaseResource resource = myVersionAdapter.getResource(theEntry); 1002 String resourceType = myContext.getResourceType(resource); 1003 nextWriteEntryRequestPartitionId = 1004 myRequestPartitionHelperService.determineCreatePartitionForRequest( 1005 requestDetailsForEntry, resource, resourceType); 1006 break; 1007 } 1008 case PUT: { 1009 IBaseResource resource = myVersionAdapter.getResource(theEntry); 1010 if (resource != null) { 1011 String resourceType = myContext.getResourceType(resource); 1012 String resourceId = null; 1013 if (resource.getIdElement().hasIdPart()) { 1014 resourceId = 1015 resourceType + "/" + resource.getIdElement().getIdPart(); 1016 } 1017 if (resourceId != null) { 1018 nextWriteEntryRequestPartitionId = theTransactionDetails.getResolvedPartition(resourceId); 1019 } 1020 if (nextWriteEntryRequestPartitionId == null) { 1021 nextWriteEntryRequestPartitionId = 1022 myRequestPartitionHelperService.determineCreatePartitionForRequest( 1023 requestDetailsForEntry, resource, resourceType); 1024 if (resourceId != null) { 1025 theTransactionDetails.addResolvedPartition( 1026 resourceId, nextWriteEntryRequestPartitionId); 1027 } 1028 } 1029 } 1030 break; 1031 } 1032 } 1033 } 1034 return nextWriteEntryRequestPartitionId; 1035 } 1036 1037 private boolean haveWriteOperationsHooks(RequestDetails theRequestDetails) { 1038 IInterceptorBroadcaster compositeBroadcaster = 1039 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, theRequestDetails); 1040 return compositeBroadcaster.hasHooks(Pointcut.STORAGE_TRANSACTION_WRITE_OPERATIONS_PRE) 1041 || compositeBroadcaster.hasHooks(Pointcut.STORAGE_TRANSACTION_WRITE_OPERATIONS_POST); 1042 } 1043 1044 private void callWriteOperationsHook( 1045 Pointcut thePointcut, 1046 RequestDetails theRequestDetails, 1047 TransactionDetails theTransactionDetails, 1048 TransactionWriteOperationsDetails theWriteOperationsDetails) { 1049 IInterceptorBroadcaster compositeBroadcaster = 1050 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, theRequestDetails); 1051 if (compositeBroadcaster.hasHooks(thePointcut)) { 1052 HookParams params = new HookParams() 1053 .add(TransactionDetails.class, theTransactionDetails) 1054 .add(TransactionWriteOperationsDetails.class, theWriteOperationsDetails); 1055 compositeBroadcaster.callHooks(thePointcut, params); 1056 } 1057 } 1058 1059 @SuppressWarnings("unchecked") 1060 private TransactionWriteOperationsDetails buildWriteOperationsDetails(List<IBase> theEntries) { 1061 TransactionWriteOperationsDetails writeOperationsDetails; 1062 List<String> updateRequestUrls = new ArrayList<>(); 1063 List<String> conditionalCreateRequestUrls = new ArrayList<>(); 1064 // Extract 1065 for (IBase nextEntry : theEntries) { 1066 String method = myVersionAdapter.getEntryRequestVerb(myContext, nextEntry); 1067 if ("PUT".equals(method)) { 1068 String requestUrl = myVersionAdapter.getEntryRequestUrl(nextEntry); 1069 if (isNotBlank(requestUrl)) { 1070 updateRequestUrls.add(requestUrl); 1071 } 1072 } else if ("POST".equals(method)) { 1073 String requestUrl = myVersionAdapter.getEntryRequestIfNoneExist(nextEntry); 1074 if (isNotBlank(requestUrl) && requestUrl.contains("?")) { 1075 conditionalCreateRequestUrls.add(requestUrl); 1076 } 1077 } 1078 } 1079 1080 writeOperationsDetails = new TransactionWriteOperationsDetails(); 1081 writeOperationsDetails.setUpdateRequestUrls(updateRequestUrls); 1082 writeOperationsDetails.setConditionalCreateRequestUrls(conditionalCreateRequestUrls); 1083 return writeOperationsDetails; 1084 } 1085 1086 private boolean isValidVerb(String theVerb) { 1087 try { 1088 return org.hl7.fhir.r4.model.Bundle.HTTPVerb.fromCode(theVerb) != null; 1089 } catch (FHIRException theE) { 1090 return false; 1091 } 1092 } 1093 1094 /** 1095 * This method is called for nested bundles (e.g. if we received a transaction with an entry that 1096 * was a GET search, this method is called on the bundle for the search result, that will be placed in the 1097 * outer bundle). This method applies the _summary and _content parameters to the output of 1098 * that bundle. 1099 * <p> 1100 * TODO: This isn't the most efficient way of doing this.. hopefully we can come up with something better in the future. 1101 */ 1102 private IBaseResource filterNestedBundle(RequestDetails theRequestDetails, IBaseResource theResource) { 1103 IParser p = myContext.newJsonParser(); 1104 RestfulServerUtils.configureResponseParser(theRequestDetails, p); 1105 return p.parseResource(theResource.getClass(), p.encodeResourceToString(theResource)); 1106 } 1107 1108 protected void validateDependencies() { 1109 Validate.notNull(myContext); 1110 Validate.notNull(myTxManager); 1111 } 1112 1113 private IIdType newIdType(String theValue) { 1114 return myContext.getVersion().newIdType().setValue(theValue); 1115 } 1116 1117 /** 1118 * Searches for duplicate conditional creates and consolidates them. 1119 */ 1120 @SuppressWarnings("unchecked") 1121 private void consolidateDuplicateConditionals( 1122 RequestDetails theRequestDetails, String theActionName, List<IBase> theEntries) { 1123 final Set<String> keysWithNoFullUrl = new HashSet<>(); 1124 final HashMap<String, String> keyToUuid = new HashMap<>(); 1125 1126 for (int index = 0, originalIndex = 0; index < theEntries.size(); index++, originalIndex++) { 1127 IBase nextReqEntry = theEntries.get(index); 1128 IBaseResource resource = myVersionAdapter.getResource(nextReqEntry); 1129 if (resource != null) { 1130 String verb = myVersionAdapter.getEntryRequestVerb(myContext, nextReqEntry); 1131 String entryFullUrl = myVersionAdapter.getFullUrl(nextReqEntry); 1132 String requestUrl = myVersionAdapter.getEntryRequestUrl(nextReqEntry); 1133 String ifNoneExist = myVersionAdapter.getEntryRequestIfNoneExist(nextReqEntry); 1134 1135 // Conditional UPDATE 1136 boolean consolidateEntryCandidate = false; 1137 String conditionalUrl; 1138 switch (verb) { 1139 case "PUT": 1140 conditionalUrl = requestUrl; 1141 if (isNotBlank(requestUrl)) { 1142 int questionMarkIndex = requestUrl.indexOf('?'); 1143 if (questionMarkIndex >= 0 && requestUrl.length() > (questionMarkIndex + 1)) { 1144 consolidateEntryCandidate = true; 1145 } 1146 } 1147 break; 1148 1149 // Conditional CREATE 1150 case "POST": 1151 conditionalUrl = ifNoneExist; 1152 if (isNotBlank(ifNoneExist)) { 1153 if (isBlank(entryFullUrl) || !entryFullUrl.equals(requestUrl)) { 1154 consolidateEntryCandidate = true; 1155 } 1156 } 1157 break; 1158 1159 default: 1160 continue; 1161 } 1162 1163 if (isNotBlank(conditionalUrl) && !conditionalUrl.contains("?")) { 1164 conditionalUrl = myContext.getResourceType(resource) + "?" + conditionalUrl; 1165 } 1166 1167 String key = verb + "|" + conditionalUrl; 1168 if (consolidateEntryCandidate) { 1169 if (isBlank(entryFullUrl)) { 1170 if (isNotBlank(conditionalUrl)) { 1171 if (!keysWithNoFullUrl.add(key)) { 1172 throw new InvalidRequestException(Msg.code(2008) + "Unable to process " + theActionName 1173 + " - Request contains multiple anonymous entries (Bundle.entry.fullUrl not populated) with conditional URL: \"" 1174 + UrlUtil.sanitizeUrlPart(conditionalUrl) 1175 + "\". Does transaction request contain duplicates?"); 1176 } 1177 } 1178 } else { 1179 if (!keyToUuid.containsKey(key)) { 1180 keyToUuid.put(key, entryFullUrl); 1181 } else { 1182 String msg = "Discarding transaction bundle entry " + originalIndex 1183 + " as it contained a duplicate conditional " + verb; 1184 ourLog.info(msg); 1185 // Interceptor broadcast: JPA_PERFTRACE_INFO 1186 IInterceptorBroadcaster compositeBroadcaster = 1187 CompositeInterceptorBroadcaster.newCompositeBroadcaster( 1188 myInterceptorBroadcaster, theRequestDetails); 1189 if (compositeBroadcaster.hasHooks(Pointcut.JPA_PERFTRACE_WARNING)) { 1190 StorageProcessingMessage message = new StorageProcessingMessage().setMessage(msg); 1191 HookParams params = new HookParams() 1192 .add(RequestDetails.class, theRequestDetails) 1193 .addIfMatchesType(ServletRequestDetails.class, theRequestDetails) 1194 .add(StorageProcessingMessage.class, message); 1195 compositeBroadcaster.callHooks(Pointcut.JPA_PERFTRACE_INFO, params); 1196 } 1197 1198 theEntries.remove(index); 1199 index--; 1200 String existingUuid = keyToUuid.get(key); 1201 replaceReferencesInEntriesWithConsolidatedUUID(theEntries, entryFullUrl, existingUuid); 1202 } 1203 } 1204 } 1205 } 1206 } 1207 } 1208 1209 /** 1210 * Iterates over all entries, and if it finds any which have references which match the fullUrl of the entry that was consolidated out 1211 * replace them with our new consolidated UUID 1212 */ 1213 private void replaceReferencesInEntriesWithConsolidatedUUID( 1214 List<IBase> theEntries, String theEntryFullUrl, String existingUuid) { 1215 for (IBase nextEntry : theEntries) { 1216 @SuppressWarnings("unchecked") 1217 IBaseResource nextResource = myVersionAdapter.getResource(nextEntry); 1218 if (nextResource != null) { 1219 for (IBaseReference nextReference : 1220 myContext.newTerser().getAllPopulatedChildElementsOfType(nextResource, IBaseReference.class)) { 1221 // We're interested in any references directly to the placeholder ID, but also 1222 // references that have a resource target that has the placeholder ID. 1223 String nextReferenceId = nextReference.getReferenceElement().getValue(); 1224 if (isBlank(nextReferenceId) && nextReference.getResource() != null) { 1225 nextReferenceId = 1226 nextReference.getResource().getIdElement().getValue(); 1227 } 1228 if (theEntryFullUrl.equals(nextReferenceId)) { 1229 nextReference.setReference(existingUuid); 1230 nextReference.setResource(null); 1231 } 1232 } 1233 } 1234 } 1235 } 1236 1237 /** 1238 * Retrieves the next resource id (IIdType) from the base resource and next request entry. 1239 * 1240 * @param theBaseResource - base resource 1241 * @param theNextReqEntry - next request entry 1242 * @param theAllIds - set of all IIdType values 1243 * @param theVerb 1244 * @return 1245 */ 1246 private IIdType getNextResourceIdFromBaseResource( 1247 IBaseResource theBaseResource, IBase theNextReqEntry, Set<IIdType> theAllIds, String theVerb) { 1248 IIdType nextResourceId = null; 1249 if (theBaseResource != null) { 1250 nextResourceId = theBaseResource.getIdElement(); 1251 1252 @SuppressWarnings("unchecked") 1253 String fullUrl = myVersionAdapter.getFullUrl(theNextReqEntry); 1254 if (isNotBlank(fullUrl)) { 1255 IIdType fullUrlIdType = newIdType(fullUrl); 1256 if (isPlaceholder(fullUrlIdType)) { 1257 nextResourceId = fullUrlIdType; 1258 } else if (!nextResourceId.hasIdPart()) { 1259 nextResourceId = fullUrlIdType; 1260 } 1261 } 1262 1263 if (nextResourceId.hasIdPart() && !isPlaceholder(nextResourceId)) { 1264 int colonIndex = nextResourceId.getIdPart().indexOf(':'); 1265 if (colonIndex != -1) { 1266 if (INVALID_PLACEHOLDER_PATTERN 1267 .matcher(nextResourceId.getIdPart()) 1268 .matches()) { 1269 throw new InvalidRequestException( 1270 Msg.code(533) + "Invalid placeholder ID found: " + nextResourceId.getIdPart() 1271 + " - Must be of the form 'urn:uuid:[uuid]' or 'urn:oid:[oid]'"); 1272 } 1273 } 1274 } 1275 1276 if (nextResourceId.hasIdPart() && !nextResourceId.hasResourceType() && !isPlaceholder(nextResourceId)) { 1277 nextResourceId = newIdType(toResourceName(theBaseResource.getClass()), nextResourceId.getIdPart()); 1278 theBaseResource.setId(nextResourceId); 1279 } 1280 1281 /* 1282 * Ensure that the bundle doesn't have any duplicates, since this causes all kinds of weirdness 1283 */ 1284 if (isPlaceholder(nextResourceId)) { 1285 if (!theAllIds.add(nextResourceId)) { 1286 throw new InvalidRequestException(Msg.code(534) 1287 + myContext 1288 .getLocalizer() 1289 .getMessage( 1290 BaseStorageDao.class, 1291 "transactionContainsMultipleWithDuplicateId", 1292 nextResourceId)); 1293 } 1294 } else if (nextResourceId.hasResourceType() && nextResourceId.hasIdPart() && !"POST".equals(theVerb)) { 1295 IIdType nextId = nextResourceId.toUnqualifiedVersionless(); 1296 if (!theAllIds.add(nextId)) { 1297 throw new InvalidRequestException(Msg.code(535) 1298 + myContext 1299 .getLocalizer() 1300 .getMessage( 1301 BaseStorageDao.class, 1302 "transactionContainsMultipleWithDuplicateId", 1303 nextId)); 1304 } 1305 } 1306 } 1307 1308 return nextResourceId; 1309 } 1310 1311 /** 1312 * After pre-hooks have been called 1313 */ 1314 @SuppressWarnings({"unchecked", "rawtypes"}) 1315 protected EntriesToProcessMap doTransactionWriteOperations( 1316 final RequestDetails theRequest, 1317 String theActionName, 1318 TransactionDetails theTransactionDetails, 1319 Set<IIdType> theAllIds, 1320 IdSubstitutionMap theIdSubstitutions, 1321 Map<IIdType, DaoMethodOutcome> theIdToPersistedOutcome, 1322 IBaseBundle theResponse, 1323 IdentityHashMap<IBase, Integer> theOriginalRequestOrder, 1324 List<IBase> theEntries, 1325 StopWatch theTransactionStopWatch) { 1326 1327 // During a transaction, we don't execute hooks, instead, we execute them all post-transaction. 1328 theTransactionDetails.beginAcceptingDeferredInterceptorBroadcasts( 1329 Pointcut.STORAGE_PRECOMMIT_RESOURCE_CREATED, 1330 Pointcut.STORAGE_PRECOMMIT_RESOURCE_UPDATED, 1331 Pointcut.STORAGE_PRECOMMIT_RESOURCE_DELETED); 1332 try { 1333 Set<String> deletedResources = new HashSet<>(); 1334 DeleteConflictList deleteConflicts = new DeleteConflictList(); 1335 EntriesToProcessMap entriesToProcess = new EntriesToProcessMap(); 1336 Set<IIdType> nonUpdatedEntities = new HashSet<>(); 1337 Set<IBasePersistedResource> updatedEntities = new HashSet<>(); 1338 Map<String, IIdType> conditionalUrlToIdMap = new HashMap<>(); 1339 List<IBaseResource> updatedResources = new ArrayList<>(); 1340 Map<String, Class<? extends IBaseResource>> conditionalRequestUrls = new HashMap<>(); 1341 1342 /* 1343 * Look for duplicate conditional creates and consolidate them 1344 */ 1345 consolidateDuplicateConditionals(theRequest, theActionName, theEntries); 1346 1347 /* 1348 * Loop through the request and process any entries of type 1349 * PUT, POST or DELETE 1350 */ 1351 String previousVerb = null; 1352 for (int i = 0; i < theEntries.size(); i++) { 1353 if (i % 250 == 0) { 1354 ourLog.debug("Processed {} non-GET entries out of {} in transaction", i, theEntries.size()); 1355 } 1356 1357 IBase nextReqEntry = theEntries.get(i); 1358 1359 String verb = myVersionAdapter.getEntryRequestVerb(myContext, nextReqEntry); 1360 1361 if (previousVerb != null && !previousVerb.equals(verb)) { 1362 handleVerbChangeInTransactionWriteOperations(); 1363 } 1364 previousVerb = verb; 1365 if ("GET".equals(verb)) { 1366 continue; 1367 } 1368 1369 String url = extractAndVerifyTransactionUrlForEntry(nextReqEntry, verb); 1370 RequestDetails requestDetailsForEntry = 1371 createRequestDetailsForWriteEntry(theRequest, nextReqEntry, url, verb); 1372 1373 IBaseResource res = myVersionAdapter.getResource(nextReqEntry); 1374 IIdType nextResourceId = getNextResourceIdFromBaseResource(res, nextReqEntry, theAllIds, verb); 1375 1376 String resourceType = res != null ? myContext.getResourceType(res) : null; 1377 Integer order = theOriginalRequestOrder.get(nextReqEntry); 1378 IBase nextRespEntry = 1379 (IBase) myVersionAdapter.getEntries(theResponse).get(order); 1380 1381 theTransactionStopWatch.startTask( 1382 "Bundle.entry[" + i + "]: " + verb + " " + defaultString(resourceType)); 1383 1384 if (res != null) { 1385 String previousResourceId = res.getIdElement().getValue(); 1386 theTransactionDetails.addRollbackUndoAction(() -> res.setId(previousResourceId)); 1387 } 1388 1389 switch (verb) { 1390 case "POST": { 1391 // CREATE 1392 validateResourcePresent(res, order, verb); 1393 1394 IFhirResourceDao resourceDao = getDaoOrThrowException(res.getClass()); 1395 res.setId((String) null); 1396 1397 DaoMethodOutcome outcome; 1398 String matchUrl = myVersionAdapter.getEntryRequestIfNoneExist(nextReqEntry); 1399 matchUrl = performIdSubstitutionsInMatchUrl(theIdSubstitutions, matchUrl); 1400 // create individual resource 1401 outcome = 1402 resourceDao.create(res, matchUrl, false, requestDetailsForEntry, theTransactionDetails); 1403 setConditionalUrlToBeValidatedLater(conditionalUrlToIdMap, matchUrl, outcome.getId()); 1404 res.setId(outcome.getId()); 1405 1406 if (nextResourceId != null) { 1407 handleTransactionCreateOrUpdateOutcome( 1408 theIdSubstitutions, 1409 theIdToPersistedOutcome, 1410 nextResourceId, 1411 outcome, 1412 nextRespEntry, 1413 resourceType, 1414 res, 1415 requestDetailsForEntry); 1416 } 1417 entriesToProcess.put(nextRespEntry, outcome.getId(), nextRespEntry); 1418 theTransactionDetails.addResolvedResource(outcome.getId(), outcome::getResource); 1419 if (outcome.getCreated() == false) { 1420 nonUpdatedEntities.add(outcome.getId()); 1421 } else { 1422 if (isNotBlank(matchUrl)) { 1423 conditionalRequestUrls.put(matchUrl, res.getClass()); 1424 } 1425 } 1426 1427 break; 1428 } 1429 case "DELETE": { 1430 // DELETE 1431 UrlUtil.UrlParts parts = UrlUtil.parseUrl(url); 1432 IFhirResourceDao<? extends IBaseResource> dao = toDao(parts, verb, url); 1433 int status = Constants.STATUS_HTTP_204_NO_CONTENT; 1434 if (parts.getResourceId() != null) { 1435 IIdType deleteId = newIdType(parts.getResourceType(), parts.getResourceId()); 1436 if (!deletedResources.contains(deleteId.getValueAsString())) { 1437 DaoMethodOutcome outcome = dao.delete( 1438 deleteId, deleteConflicts, requestDetailsForEntry, theTransactionDetails); 1439 if (outcome.getEntity() != null) { 1440 deletedResources.add(deleteId.getValueAsString()); 1441 entriesToProcess.put(nextRespEntry, outcome.getId(), nextRespEntry); 1442 } 1443 myVersionAdapter.setResponseOutcome(nextRespEntry, outcome.getOperationOutcome()); 1444 } 1445 } else { 1446 String matchUrl = parts.getResourceType() + '?' + parts.getParams(); 1447 matchUrl = performIdSubstitutionsInMatchUrl(theIdSubstitutions, matchUrl); 1448 DeleteMethodOutcome deleteOutcome = dao.deleteByUrl( 1449 matchUrl, deleteConflicts, requestDetailsForEntry, theTransactionDetails); 1450 setConditionalUrlToBeValidatedLater(conditionalUrlToIdMap, matchUrl, deleteOutcome.getId()); 1451 List<? extends IBasePersistedResource> allDeleted = deleteOutcome.getDeletedEntities(); 1452 for (IBasePersistedResource deleted : allDeleted) { 1453 deletedResources.add(deleted.getIdDt() 1454 .toUnqualifiedVersionless() 1455 .getValueAsString()); 1456 } 1457 if (allDeleted.isEmpty()) { 1458 status = Constants.STATUS_HTTP_204_NO_CONTENT; 1459 } 1460 1461 myVersionAdapter.setResponseOutcome(nextRespEntry, deleteOutcome.getOperationOutcome()); 1462 } 1463 1464 myVersionAdapter.setResponseStatus(nextRespEntry, toStatusString(status)); 1465 1466 break; 1467 } 1468 case "PUT": { 1469 // UPDATE 1470 validateResourcePresent(res, order, verb); 1471 @SuppressWarnings("rawtypes") 1472 IFhirResourceDao resourceDao = getDaoOrThrowException(res.getClass()); 1473 1474 DaoMethodOutcome outcome; 1475 UrlUtil.UrlParts parts = UrlUtil.parseUrl(url); 1476 if (isNotBlank(parts.getResourceId())) { 1477 String version = null; 1478 if (isNotBlank(myVersionAdapter.getEntryRequestIfMatch(nextReqEntry))) { 1479 version = ParameterUtil.parseETagValue( 1480 myVersionAdapter.getEntryRequestIfMatch(nextReqEntry)); 1481 } 1482 res.setId(newIdType(parts.getResourceType(), parts.getResourceId(), version)); 1483 outcome = resourceDao.update( 1484 res, null, false, false, requestDetailsForEntry, theTransactionDetails); 1485 } else { 1486 if (!shouldConditionalUpdateMatchId(theTransactionDetails, res.getIdElement())) { 1487 res.setId((String) null); 1488 } 1489 String matchUrl; 1490 if (isNotBlank(parts.getParams())) { 1491 matchUrl = parts.getResourceType() + '?' + parts.getParams(); 1492 } else { 1493 matchUrl = parts.getResourceType(); 1494 } 1495 matchUrl = performIdSubstitutionsInMatchUrl(theIdSubstitutions, matchUrl); 1496 outcome = resourceDao.update( 1497 res, matchUrl, false, false, requestDetailsForEntry, theTransactionDetails); 1498 setConditionalUrlToBeValidatedLater(conditionalUrlToIdMap, matchUrl, outcome.getId()); 1499 if (Boolean.TRUE.equals(outcome.getCreated())) { 1500 conditionalRequestUrls.put(matchUrl, res.getClass()); 1501 } 1502 } 1503 1504 if (outcome.getCreated() == Boolean.FALSE 1505 || (outcome.getCreated() == Boolean.TRUE 1506 && outcome.getId().getVersionIdPartAsLong() > 1)) { 1507 updatedEntities.add(outcome.getEntity()); 1508 if (outcome.getResource() != null) { 1509 updatedResources.add(outcome.getResource()); 1510 } 1511 } 1512 1513 theTransactionDetails.addResolvedResource(outcome.getId(), outcome::getResource); 1514 handleTransactionCreateOrUpdateOutcome( 1515 theIdSubstitutions, 1516 theIdToPersistedOutcome, 1517 nextResourceId, 1518 outcome, 1519 nextRespEntry, 1520 resourceType, 1521 res, 1522 requestDetailsForEntry); 1523 entriesToProcess.put(nextRespEntry, outcome.getId(), nextRespEntry); 1524 break; 1525 } 1526 case "PATCH": { 1527 // PATCH 1528 validateResourcePresent(res, order, verb); 1529 1530 UrlUtil.UrlParts parts = UrlUtil.parseUrl(url); 1531 1532 String matchUrl = toMatchUrl(nextReqEntry); 1533 matchUrl = performIdSubstitutionsInMatchUrl(theIdSubstitutions, matchUrl); 1534 String patchBody = null; 1535 String contentType; 1536 IBaseParameters patchBodyParameters = null; 1537 PatchTypeEnum patchType = null; 1538 1539 if (res instanceof IBaseBinary) { 1540 IBaseBinary binary = (IBaseBinary) res; 1541 if (binary.getContent() != null && binary.getContent().length > 0) { 1542 patchBody = toUtf8String(binary.getContent()); 1543 } 1544 contentType = binary.getContentType(); 1545 patchType = 1546 PatchTypeEnum.forContentTypeOrThrowInvalidRequestException(myContext, contentType); 1547 if (patchType == PatchTypeEnum.FHIR_PATCH_JSON 1548 || patchType == PatchTypeEnum.FHIR_PATCH_XML) { 1549 String msg = myContext 1550 .getLocalizer() 1551 .getMessage( 1552 BaseTransactionProcessor.class, "fhirPatchShouldNotUseBinaryResource"); 1553 throw new InvalidRequestException(Msg.code(536) + msg); 1554 } 1555 } else if (res instanceof IBaseParameters) { 1556 patchBodyParameters = (IBaseParameters) res; 1557 patchType = PatchTypeEnum.FHIR_PATCH_JSON; 1558 } 1559 1560 if (patchBodyParameters == null) { 1561 if (isBlank(patchBody)) { 1562 String msg = myContext 1563 .getLocalizer() 1564 .getMessage(BaseTransactionProcessor.class, "missingPatchBody"); 1565 throw new InvalidRequestException(Msg.code(537) + msg); 1566 } 1567 } 1568 1569 IFhirResourceDao<? extends IBaseResource> dao = toDao(parts, verb, url); 1570 IIdType patchId = 1571 myContext.getVersion().newIdType(parts.getResourceType(), parts.getResourceId()); 1572 1573 String conditionalUrl; 1574 if (isNull(patchId.getIdPart())) { 1575 conditionalUrl = url; 1576 } else { 1577 conditionalUrl = matchUrl; 1578 String ifMatch = myVersionAdapter.getEntryRequestIfMatch(nextReqEntry); 1579 if (isNotBlank(ifMatch)) { 1580 patchId = UpdateMethodBinding.applyETagAsVersion(ifMatch, patchId); 1581 } 1582 } 1583 1584 DaoMethodOutcome outcome = dao.patchInTransaction( 1585 patchId, 1586 conditionalUrl, 1587 false, 1588 patchType, 1589 patchBody, 1590 patchBodyParameters, 1591 requestDetailsForEntry, 1592 theTransactionDetails); 1593 setConditionalUrlToBeValidatedLater(conditionalUrlToIdMap, matchUrl, outcome.getId()); 1594 updatedEntities.add(outcome.getEntity()); 1595 if (outcome.getResource() != null) { 1596 updatedResources.add(outcome.getResource()); 1597 } 1598 if (nextResourceId != null) { 1599 handleTransactionCreateOrUpdateOutcome( 1600 theIdSubstitutions, 1601 theIdToPersistedOutcome, 1602 nextResourceId, 1603 outcome, 1604 nextRespEntry, 1605 resourceType, 1606 res, 1607 requestDetailsForEntry); 1608 } 1609 entriesToProcess.put(nextRespEntry, outcome.getId(), nextRespEntry); 1610 1611 break; 1612 } 1613 case "GET": 1614 break; 1615 default: 1616 throw new InvalidRequestException( 1617 Msg.code(538) + "Unable to handle verb in transaction: " + verb); 1618 } 1619 1620 theTransactionStopWatch.endCurrentTask(); 1621 } 1622 1623 postTransactionProcess(theTransactionDetails); 1624 1625 /* 1626 * Make sure that there are no conflicts from deletions. E.g. we can't delete something 1627 * if something else has a reference to it.. Unless the thing that has a reference to it 1628 * was also deleted as a part of this transaction, which is why we check this now at the 1629 * end. 1630 */ 1631 checkForDeleteConflicts(deleteConflicts, deletedResources, updatedResources); 1632 1633 theIdToPersistedOutcome.entrySet().forEach(idAndOutcome -> { 1634 theTransactionDetails.addResolvedResourceId( 1635 idAndOutcome.getKey(), idAndOutcome.getValue().getPersistentId()); 1636 }); 1637 1638 /* 1639 * Perform ID substitutions and then index each resource we have saved 1640 */ 1641 1642 resolveReferencesThenSaveAndIndexResources( 1643 theRequest, 1644 theTransactionDetails, 1645 theIdSubstitutions, 1646 theIdToPersistedOutcome, 1647 theTransactionStopWatch, 1648 entriesToProcess, 1649 nonUpdatedEntities, 1650 updatedEntities); 1651 1652 theTransactionStopWatch.endCurrentTask(); 1653 1654 // flush writes to db 1655 theTransactionStopWatch.startTask("Flush writes to database"); 1656 1657 // flush the changes 1658 flushSession(theIdToPersistedOutcome); 1659 1660 theTransactionStopWatch.endCurrentTask(); 1661 1662 /* 1663 * Double check we didn't allow any duplicates we shouldn't have 1664 */ 1665 if (conditionalRequestUrls.size() > 0) { 1666 theTransactionStopWatch.startTask("Check for conflicts in conditional resources"); 1667 } 1668 if (!myStorageSettings.isMassIngestionMode()) { 1669 validateNoDuplicates( 1670 theRequest, 1671 theTransactionDetails, 1672 theActionName, 1673 conditionalRequestUrls, 1674 theIdToPersistedOutcome.values()); 1675 } 1676 1677 theTransactionStopWatch.endCurrentTask(); 1678 if (conditionalUrlToIdMap.size() > 0) { 1679 theTransactionStopWatch.startTask( 1680 "Check that all conditionally created/updated entities actually match their conditionals."); 1681 } 1682 1683 if (!myStorageSettings.isMassIngestionMode()) { 1684 validateAllInsertsMatchTheirConditionalUrls(theIdToPersistedOutcome, conditionalUrlToIdMap, theRequest); 1685 } 1686 theTransactionStopWatch.endCurrentTask(); 1687 1688 for (IIdType next : theAllIds) { 1689 IIdType replacement = theIdSubstitutions.getForSource(next); 1690 if (replacement != null && !replacement.equals(next)) { 1691 ourLog.debug( 1692 "Placeholder resource ID \"{}\" was replaced with permanent ID \"{}\"", next, replacement); 1693 } 1694 } 1695 1696 IInterceptorBroadcaster compositeBroadcaster = 1697 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, theRequest); 1698 ListMultimap<Pointcut, HookParams> deferredBroadcastEvents = 1699 theTransactionDetails.endAcceptingDeferredInterceptorBroadcasts(); 1700 for (Map.Entry<Pointcut, HookParams> nextEntry : deferredBroadcastEvents.entries()) { 1701 Pointcut nextPointcut = nextEntry.getKey(); 1702 HookParams nextParams = nextEntry.getValue(); 1703 compositeBroadcaster.callHooks(nextPointcut, nextParams); 1704 } 1705 1706 DeferredInterceptorBroadcasts deferredInterceptorBroadcasts = 1707 new DeferredInterceptorBroadcasts(deferredBroadcastEvents); 1708 HookParams params = new HookParams() 1709 .add(RequestDetails.class, theRequest) 1710 .addIfMatchesType(ServletRequestDetails.class, theRequest) 1711 .add(DeferredInterceptorBroadcasts.class, deferredInterceptorBroadcasts) 1712 .add(TransactionDetails.class, theTransactionDetails) 1713 .add(IBaseBundle.class, theResponse); 1714 compositeBroadcaster.callHooks(Pointcut.STORAGE_TRANSACTION_PROCESSED, params); 1715 1716 theTransactionDetails.deferredBroadcastProcessingFinished(); 1717 1718 // finishedCallingDeferredInterceptorBroadcasts 1719 1720 return entriesToProcess; 1721 1722 } finally { 1723 if (theTransactionDetails.isAcceptingDeferredInterceptorBroadcasts()) { 1724 theTransactionDetails.endAcceptingDeferredInterceptorBroadcasts(); 1725 } 1726 } 1727 } 1728 1729 /** 1730 * Subclasses may override this in order to invoke specific operations when 1731 * we're finished handling all the write entries in the transaction bundle 1732 * with a given verb. 1733 */ 1734 protected void handleVerbChangeInTransactionWriteOperations() { 1735 // nothing 1736 } 1737 1738 /** 1739 * Implement to handle post transaction processing 1740 */ 1741 protected void postTransactionProcess(TransactionDetails theTransactionDetails) { 1742 // nothing 1743 } 1744 1745 /** 1746 * Check for if a resource id should be matched in a conditional update 1747 * If the FHIR version is older than R4, it follows the old specifications and does not match 1748 * If the resource id has been resolved, then it is an existing resource and does not need to be matched 1749 * If the resource id is local or a placeholder, the id is temporary and should not be matched 1750 */ 1751 private boolean shouldConditionalUpdateMatchId(TransactionDetails theTransactionDetails, IIdType theId) { 1752 if (myContext.getVersion().getVersion().isOlderThan(FhirVersionEnum.R4)) { 1753 return false; 1754 } 1755 if (theTransactionDetails.hasResolvedResourceId(theId) 1756 && !theTransactionDetails.isResolvedResourceIdEmpty(theId)) { 1757 return false; 1758 } 1759 if (theId != null && theId.getValue() != null) { 1760 return !(theId.getValue().startsWith("urn:") || theId.getValue().startsWith("#")); 1761 } 1762 return true; 1763 } 1764 1765 private boolean shouldSwapBinaryToActualResource( 1766 IBaseResource theResource, String theResourceType, IIdType theNextResourceId) { 1767 if ("Binary".equalsIgnoreCase(theResourceType) 1768 && theNextResourceId.getResourceType() != null 1769 && !theNextResourceId.getResourceType().equalsIgnoreCase("Binary")) { 1770 return true; 1771 } else { 1772 return false; 1773 } 1774 } 1775 1776 private void setConditionalUrlToBeValidatedLater( 1777 Map<String, IIdType> theConditionalUrlToIdMap, String theMatchUrl, IIdType theId) { 1778 if (!isBlank(theMatchUrl)) { 1779 theConditionalUrlToIdMap.put(theMatchUrl, theId); 1780 } 1781 } 1782 1783 /** 1784 * After transaction processing and resolution of indexes and references, we want to validate that the resources that were stored _actually_ 1785 * match the conditional URLs that they were brought in on. 1786 */ 1787 private void validateAllInsertsMatchTheirConditionalUrls( 1788 Map<IIdType, DaoMethodOutcome> theIdToPersistedOutcome, 1789 Map<String, IIdType> conditionalUrlToIdMap, 1790 RequestDetails theRequest) { 1791 conditionalUrlToIdMap.entrySet().stream() 1792 .filter(entry -> entry.getKey() != null) 1793 .forEach(entry -> { 1794 String matchUrl = entry.getKey(); 1795 IIdType value = entry.getValue(); 1796 DaoMethodOutcome daoMethodOutcome = theIdToPersistedOutcome.get(value); 1797 if (daoMethodOutcome != null 1798 && !daoMethodOutcome.isNop() 1799 && daoMethodOutcome.getResource() != null) { 1800 InMemoryMatchResult match = 1801 mySearchParamMatcher.match(matchUrl, daoMethodOutcome.getResource(), theRequest); 1802 if (ourLog.isDebugEnabled()) { 1803 ourLog.debug( 1804 "Checking conditional URL [{}] against resource with ID [{}]: Supported?:[{}], Matched?:[{}]", 1805 matchUrl, 1806 value, 1807 match.supported(), 1808 match.matched()); 1809 } 1810 if (match.supported()) { 1811 if (!match.matched()) { 1812 throw new PreconditionFailedException(Msg.code(539) + "Invalid conditional URL \"" 1813 + matchUrl + "\". The given resource is not matched by this URL."); 1814 } 1815 } 1816 } 1817 }); 1818 } 1819 1820 /** 1821 * Checks for any delete conflicts. 1822 * 1823 * @param theDeleteConflicts - set of delete conflicts 1824 * @param theDeletedResources - set of deleted resources 1825 * @param theUpdatedResources - list of updated resources 1826 */ 1827 private void checkForDeleteConflicts( 1828 DeleteConflictList theDeleteConflicts, 1829 Set<String> theDeletedResources, 1830 List<IBaseResource> theUpdatedResources) { 1831 for (Iterator<DeleteConflict> iter = theDeleteConflicts.iterator(); iter.hasNext(); ) { 1832 DeleteConflict nextDeleteConflict = iter.next(); 1833 1834 /* 1835 * If we have a conflict, it means we can't delete Resource/A because 1836 * Resource/B has a reference to it. We'll ignore that conflict though 1837 * if it turns out we're also deleting Resource/B in this transaction. 1838 */ 1839 if (theDeletedResources.contains( 1840 nextDeleteConflict.getSourceId().toUnqualifiedVersionless().getValue())) { 1841 iter.remove(); 1842 continue; 1843 } 1844 1845 /* 1846 * And then, this is kind of a last ditch check. It's also ok to delete 1847 * Resource/A if Resource/B isn't being deleted, but it is being UPDATED 1848 * in this transaction, and the updated version of it has no references 1849 * to Resource/A any more. 1850 */ 1851 String sourceId = 1852 nextDeleteConflict.getSourceId().toUnqualifiedVersionless().getValue(); 1853 String targetId = 1854 nextDeleteConflict.getTargetId().toUnqualifiedVersionless().getValue(); 1855 Optional<IBaseResource> updatedSource = theUpdatedResources.stream() 1856 .filter(t -> sourceId.equals( 1857 t.getIdElement().toUnqualifiedVersionless().getValue())) 1858 .findFirst(); 1859 if (updatedSource.isPresent()) { 1860 List<ResourceReferenceInfo> referencesInSource = 1861 myContext.newTerser().getAllResourceReferences(updatedSource.get()); 1862 boolean sourceStillReferencesTarget = referencesInSource.stream() 1863 .anyMatch(t -> targetId.equals(t.getResourceReference() 1864 .getReferenceElement() 1865 .toUnqualifiedVersionless() 1866 .getValue())); 1867 if (!sourceStillReferencesTarget) { 1868 iter.remove(); 1869 } 1870 } 1871 } 1872 DeleteConflictUtil.validateDeleteConflictsEmptyOrThrowException(myContext, theDeleteConflicts); 1873 } 1874 1875 /** 1876 * This method replaces any placeholder references in the 1877 * source transaction Bundle with their actual targets, then stores the resource contents and indexes 1878 * in the database. This is trickier than you'd think because of a couple of possibilities during the 1879 * save: 1880 * * There may be resources that have not changed (e.g. an update/PUT with a resource body identical 1881 * to what is already in the database) 1882 * * There may be resources with auto-versioned references, meaning we're replacing certain references 1883 * in the resource with a versioned references, referencing the current version at the time of the 1884 * transaction processing 1885 * * There may by auto-versioned references pointing to these unchanged targets 1886 * <p> 1887 * If we're not doing any auto-versioned references, we'll just iterate through all resources in the 1888 * transaction and save them one at a time. 1889 * <p> 1890 * However, if we have any auto-versioned references we do this in 2 passes: First the resources from the 1891 * transaction that don't have any auto-versioned references are stored. We do them first since there's 1892 * a chance they may be a NOP and we'll need to account for their version number not actually changing. 1893 * Then we do a second pass for any resources that have auto-versioned references. These happen in a separate 1894 * pass because it's too complex to try and insert the auto-versioned references and still 1895 * account for NOPs, so we block NOPs in that pass. 1896 */ 1897 private void resolveReferencesThenSaveAndIndexResources( 1898 RequestDetails theRequest, 1899 TransactionDetails theTransactionDetails, 1900 IdSubstitutionMap theIdSubstitutions, 1901 Map<IIdType, DaoMethodOutcome> theIdToPersistedOutcome, 1902 StopWatch theTransactionStopWatch, 1903 EntriesToProcessMap theEntriesToProcess, 1904 Set<IIdType> theNonUpdatedEntities, 1905 Set<IBasePersistedResource> theUpdatedEntities) { 1906 FhirTerser terser = myContext.newTerser(); 1907 theTransactionStopWatch.startTask("Index " + theIdToPersistedOutcome.size() + " resources"); 1908 IdentityHashMap<DaoMethodOutcome, Set<IBaseReference>> deferredIndexesForAutoVersioning = null; 1909 int i = 0; 1910 for (DaoMethodOutcome nextOutcome : theIdToPersistedOutcome.values()) { 1911 1912 if (i++ % 250 == 0) { 1913 ourLog.debug( 1914 "Have indexed {} entities out of {} in transaction", 1915 i, 1916 theIdToPersistedOutcome.values().size()); 1917 } 1918 1919 if (nextOutcome.isNop()) { 1920 continue; 1921 } 1922 1923 IBaseResource nextResource = nextOutcome.getResource(); 1924 if (nextResource == null) { 1925 continue; 1926 } 1927 1928 Set<IBaseReference> referencesToAutoVersion = 1929 BaseStorageDao.extractReferencesToAutoVersion(myContext, myStorageSettings, nextResource); 1930 Set<IBaseReference> referencesToKeepClientSuppliedVersion = 1931 BaseStorageDao.extractReferencesToAvoidReplacement(myContext, nextResource); 1932 1933 if (referencesToAutoVersion.isEmpty()) { 1934 // no references to autoversion - we can do the resolve and save now 1935 resolveReferencesThenSaveAndIndexResource( 1936 theRequest, 1937 theTransactionDetails, 1938 theIdSubstitutions, 1939 theIdToPersistedOutcome, 1940 theEntriesToProcess, 1941 theNonUpdatedEntities, 1942 theUpdatedEntities, 1943 terser, 1944 nextOutcome, 1945 nextResource, 1946 referencesToAutoVersion, // this is empty 1947 referencesToKeepClientSuppliedVersion); 1948 } else { 1949 // we have autoversioned things to defer until later 1950 if (deferredIndexesForAutoVersioning == null) { 1951 deferredIndexesForAutoVersioning = new IdentityHashMap<>(); 1952 } 1953 deferredIndexesForAutoVersioning.put(nextOutcome, referencesToAutoVersion); 1954 } 1955 } 1956 1957 // If we have any resources we'll be auto-versioning, index these next 1958 if (deferredIndexesForAutoVersioning != null) { 1959 for (Map.Entry<DaoMethodOutcome, Set<IBaseReference>> nextEntry : 1960 deferredIndexesForAutoVersioning.entrySet()) { 1961 DaoMethodOutcome nextOutcome = nextEntry.getKey(); 1962 Set<IBaseReference> referencesToAutoVersion = nextEntry.getValue(); 1963 IBaseResource nextResource = nextOutcome.getResource(); 1964 Set<IBaseReference> referencesToKeepClientSuppliedVersion = 1965 BaseStorageDao.extractReferencesToAvoidReplacement(myContext, nextResource); 1966 1967 resolveReferencesThenSaveAndIndexResource( 1968 theRequest, 1969 theTransactionDetails, 1970 theIdSubstitutions, 1971 theIdToPersistedOutcome, 1972 theEntriesToProcess, 1973 theNonUpdatedEntities, 1974 theUpdatedEntities, 1975 terser, 1976 nextOutcome, 1977 nextResource, 1978 referencesToAutoVersion, 1979 referencesToKeepClientSuppliedVersion); 1980 } 1981 } 1982 } 1983 1984 private void resolveReferencesThenSaveAndIndexResource( 1985 RequestDetails theRequest, 1986 TransactionDetails theTransactionDetails, 1987 IdSubstitutionMap theIdSubstitutions, 1988 Map<IIdType, DaoMethodOutcome> theIdToPersistedOutcome, 1989 EntriesToProcessMap theEntriesToProcess, 1990 Set<IIdType> theNonUpdatedEntities, 1991 Set<IBasePersistedResource> theUpdatedEntities, 1992 FhirTerser theTerser, 1993 DaoMethodOutcome theDaoMethodOutcome, 1994 IBaseResource theResource, 1995 Set<IBaseReference> theReferencesToAutoVersion, 1996 Set<IBaseReference> theReferencesToKeepClientSuppliedVersion) { 1997 // References 1998 List<ResourceReferenceInfo> allRefs = theTerser.getAllResourceReferences(theResource); 1999 for (ResourceReferenceInfo nextRef : allRefs) { 2000 IBaseReference resourceReference = nextRef.getResourceReference(); 2001 IIdType nextId = resourceReference.getReferenceElement(); 2002 IIdType newId = null; 2003 if (!nextId.hasIdPart()) { 2004 if (resourceReference.getResource() != null) { 2005 IIdType targetId = resourceReference.getResource().getIdElement(); 2006 if (targetId.getValue() == null) { 2007 // This means it's a contained resource 2008 continue; 2009 } else if (theIdSubstitutions.containsTarget(targetId)) { 2010 newId = targetId; 2011 } else { 2012 throw new InternalErrorException(Msg.code(540) 2013 + "References by resource with no reference ID are not supported in DAO layer"); 2014 } 2015 } else { 2016 continue; 2017 } 2018 } 2019 if (newId != null || theIdSubstitutions.containsSource(nextId)) { 2020 if (shouldReplaceResourceReference( 2021 theReferencesToAutoVersion, theReferencesToKeepClientSuppliedVersion, resourceReference)) { 2022 if (newId == null) { 2023 newId = theIdSubstitutions.getForSource(nextId); 2024 } 2025 if (newId != null) { 2026 ourLog.debug(" * Replacing resource ref {} with {}", nextId, newId); 2027 if (theReferencesToAutoVersion.contains(resourceReference)) { 2028 replaceResourceReference(newId, resourceReference, theTransactionDetails); 2029 } else { 2030 replaceResourceReference(newId.toVersionless(), resourceReference, theTransactionDetails); 2031 } 2032 } 2033 } 2034 } else if (nextId.getValue().startsWith("urn:")) { 2035 throw new InvalidRequestException( 2036 Msg.code(541) + "Unable to satisfy placeholder ID " + nextId.getValue() 2037 + " found in element named '" + nextRef.getName() + "' within resource of type: " 2038 + theResource.getIdElement().getResourceType()); 2039 } else { 2040 // get a map of 2041 // existing ids -> PID (for resources that exist in the DB) 2042 // should this be allPartitions? 2043 ResourcePersistentIdMap resourceVersionMap = myResourceVersionSvc.getLatestVersionIdsForResourceIds( 2044 RequestPartitionId.allPartitions(), 2045 theReferencesToAutoVersion.stream() 2046 .map(IBaseReference::getReferenceElement) 2047 .collect(Collectors.toList())); 2048 2049 for (IBaseReference baseRef : theReferencesToAutoVersion) { 2050 IIdType id = baseRef.getReferenceElement(); 2051 if (!resourceVersionMap.containsKey(id) 2052 && myStorageSettings.isAutoCreatePlaceholderReferenceTargets()) { 2053 // not in the db, but autocreateplaceholders is true 2054 // so the version we'll set is "1" (since it will be 2055 // created later) 2056 String newRef = id.withVersion("1").getValue(); 2057 id.setValue(newRef); 2058 } else { 2059 // we will add the looked up info to the transaction 2060 // for later 2061 if (resourceVersionMap.containsKey(id)) { 2062 theTransactionDetails.addResolvedResourceId( 2063 id, resourceVersionMap.getResourcePersistentId(id)); 2064 } 2065 } 2066 } 2067 2068 if (theReferencesToAutoVersion.contains(resourceReference)) { 2069 DaoMethodOutcome outcome = theIdToPersistedOutcome.get(nextId); 2070 2071 if (outcome != null && !outcome.isNop() && !Boolean.TRUE.equals(outcome.getCreated())) { 2072 replaceResourceReference(nextId, resourceReference, theTransactionDetails); 2073 } 2074 2075 // if referenced resource is not in transaction but exists in the DB, resolving its version 2076 IResourcePersistentId persistedReferenceId = resourceVersionMap.getResourcePersistentId(nextId); 2077 if (outcome == null && persistedReferenceId != null && persistedReferenceId.getVersion() != null) { 2078 IIdType newReferenceId = nextId.withVersion( 2079 persistedReferenceId.getVersion().toString()); 2080 replaceResourceReference(newReferenceId, resourceReference, theTransactionDetails); 2081 } 2082 } 2083 } 2084 } 2085 2086 // URIs 2087 Class<? extends IPrimitiveType<?>> uriType = (Class<? extends IPrimitiveType<?>>) 2088 myContext.getElementDefinition("uri").getImplementingClass(); 2089 List<? extends IPrimitiveType<?>> allUris = theTerser.getAllPopulatedChildElementsOfType(theResource, uriType); 2090 for (IPrimitiveType<?> nextRef : allUris) { 2091 if (nextRef instanceof IIdType) { 2092 continue; // No substitution on the resource ID itself! 2093 } 2094 String nextUriString = nextRef.getValueAsString(); 2095 if (isNotBlank(nextUriString)) { 2096 if (theIdSubstitutions.containsSource(nextUriString)) { 2097 IIdType newId = theIdSubstitutions.getForSource(nextUriString); 2098 ourLog.debug(" * Replacing resource ref {} with {}", nextUriString, newId); 2099 2100 String existingValue = nextRef.getValueAsString(); 2101 theTransactionDetails.addRollbackUndoAction(() -> nextRef.setValueAsString(existingValue)); 2102 2103 nextRef.setValueAsString(newId.toVersionless().getValue()); 2104 } else { 2105 ourLog.debug(" * Reference [{}] does not exist in bundle", nextUriString); 2106 } 2107 } 2108 } 2109 2110 IPrimitiveType<Date> deletedInstantOrNull = ResourceMetadataKeyEnum.DELETED_AT.get(theResource); 2111 Date deletedTimestampOrNull = deletedInstantOrNull != null ? deletedInstantOrNull.getValue() : null; 2112 2113 IFhirResourceDao<? extends IBaseResource> dao = myDaoRegistry.getResourceDao(theResource.getClass()); 2114 IJpaDao jpaDao = (IJpaDao) dao; 2115 2116 IBasePersistedResource updateOutcome = null; 2117 if (theUpdatedEntities.contains(theDaoMethodOutcome.getEntity())) { 2118 boolean forceUpdateVersion = !theReferencesToAutoVersion.isEmpty(); 2119 String matchUrl = theDaoMethodOutcome.getMatchUrl(); 2120 RestOperationTypeEnum operationType = theDaoMethodOutcome.getOperationType(); 2121 DaoMethodOutcome daoMethodOutcome = jpaDao.updateInternal( 2122 theRequest, 2123 theResource, 2124 matchUrl, 2125 true, 2126 forceUpdateVersion, 2127 theDaoMethodOutcome.getEntity(), 2128 theResource.getIdElement(), 2129 theDaoMethodOutcome.getPreviousResource(), 2130 operationType, 2131 theTransactionDetails); 2132 updateOutcome = daoMethodOutcome.getEntity(); 2133 theDaoMethodOutcome = daoMethodOutcome; 2134 } else if (!theNonUpdatedEntities.contains(theDaoMethodOutcome.getId())) { 2135 updateOutcome = jpaDao.updateEntity( 2136 theRequest, 2137 theResource, 2138 theDaoMethodOutcome.getEntity(), 2139 deletedTimestampOrNull, 2140 true, 2141 false, 2142 theTransactionDetails, 2143 false, 2144 true); 2145 } 2146 2147 // Make sure we reflect the actual final version for the resource. 2148 if (updateOutcome != null) { 2149 IIdType newId = updateOutcome.getIdDt(); 2150 2151 IIdType entryId = theEntriesToProcess.getIdWithVersionlessComparison(newId); 2152 if (entryId != null && !StringUtils.equals(entryId.getValue(), newId.getValue())) { 2153 entryId.setValue(newId.getValue()); 2154 } 2155 2156 theDaoMethodOutcome.setId(newId); 2157 2158 theIdSubstitutions.updateTargets(newId); 2159 2160 // This will only be null if we're not intending to return an OO 2161 IBaseOperationOutcome operationOutcome = theDaoMethodOutcome.getOperationOutcome(); 2162 if (operationOutcome != null) { 2163 2164 List<IIdType> autoCreatedPlaceholders = 2165 theTransactionDetails.getAutoCreatedPlaceholderResourcesAndClear(); 2166 for (IIdType autoCreatedPlaceholder : autoCreatedPlaceholders) { 2167 BaseStorageDao.addIssueToOperationOutcomeForAutoCreatedPlaceholder( 2168 myContext, autoCreatedPlaceholder, operationOutcome); 2169 } 2170 2171 IBase responseEntry = theEntriesToProcess.getResponseBundleEntryWithVersionlessComparison(newId); 2172 myVersionAdapter.setResponseOutcome(responseEntry, operationOutcome); 2173 } 2174 } 2175 } 2176 2177 /** 2178 * We should replace the references when 2179 * 1. It is not a reference we should keep the client-supplied version for as configured by `DontStripVersionsFromReferences` or 2180 * 2. It is a reference that has been identified for auto versioning or 2181 * 3. Is a placeholder reference 2182 * 2183 * @param theReferencesToAutoVersion list of references identified for auto versioning 2184 * @param theReferencesToKeepClientSuppliedVersion list of references that we should not strip the version for 2185 * @param theResourceReference the resource reference 2186 * @return true if we should replace the resource reference, false if we should keep the client provided reference 2187 */ 2188 private boolean shouldReplaceResourceReference( 2189 Set<IBaseReference> theReferencesToAutoVersion, 2190 Set<IBaseReference> theReferencesToKeepClientSuppliedVersion, 2191 IBaseReference theResourceReference) { 2192 return (!theReferencesToKeepClientSuppliedVersion.contains(theResourceReference) 2193 && myContext.getParserOptions().isStripVersionsFromReferences()) 2194 || theReferencesToAutoVersion.contains(theResourceReference) 2195 || isPlaceholder(theResourceReference.getReferenceElement()); 2196 } 2197 2198 private void replaceResourceReference( 2199 IIdType theReferenceId, IBaseReference theResourceReference, TransactionDetails theTransactionDetails) { 2200 addRollbackReferenceRestore(theTransactionDetails, theResourceReference); 2201 theResourceReference.setReference(theReferenceId.getValue()); 2202 theResourceReference.setResource(null); 2203 } 2204 2205 private void addRollbackReferenceRestore( 2206 TransactionDetails theTransactionDetails, IBaseReference resourceReference) { 2207 String existingValue = resourceReference.getReferenceElement().getValue(); 2208 theTransactionDetails.addRollbackUndoAction(() -> resourceReference.setReference(existingValue)); 2209 } 2210 2211 private void validateNoDuplicates( 2212 RequestDetails theRequest, 2213 TransactionDetails theTransactionDetails, 2214 String theActionName, 2215 Map<String, Class<? extends IBaseResource>> conditionalRequestUrls, 2216 Collection<DaoMethodOutcome> thePersistedOutcomes) { 2217 2218 Map<ResourceTable, ResourceIndexedSearchParams> existingSearchParams = 2219 theTransactionDetails.getOrCreateUserData( 2220 HapiTransactionService.XACT_USERDATA_KEY_EXISTING_SEARCH_PARAMS, Collections::emptyMap); 2221 2222 IdentityHashMap<IBaseResource, ResourceIndexedSearchParams> resourceToIndexedParams = 2223 new IdentityHashMap<>(thePersistedOutcomes.size()); 2224 thePersistedOutcomes.stream() 2225 .filter(t -> !t.isNop()) 2226 // N.B. GGG: This validation never occurs for mongo, as nothing is a ResourceTable. 2227 .filter(t -> t.getEntity() instanceof ResourceTable) 2228 .filter(t -> t.getEntity().getDeleted() == null) 2229 .filter(t -> t.getResource() != null) 2230 .forEach(t -> { 2231 ResourceTable entity = (ResourceTable) t.getEntity(); 2232 ResourceIndexedSearchParams params = existingSearchParams.get(entity); 2233 if (params == null) { 2234 params = ResourceIndexedSearchParams.withLists(entity); 2235 } 2236 resourceToIndexedParams.put(t.getResource(), params); 2237 }); 2238 2239 for (Map.Entry<String, Class<? extends IBaseResource>> nextEntry : conditionalRequestUrls.entrySet()) { 2240 String matchUrl = nextEntry.getKey(); 2241 if (isNotBlank(matchUrl)) { 2242 if (matchUrl.startsWith("?") 2243 || (!matchUrl.contains("?") 2244 && UNQUALIFIED_MATCH_URL_START.matcher(matchUrl).find())) { 2245 StringBuilder b = new StringBuilder(); 2246 b.append(myContext.getResourceType(nextEntry.getValue())); 2247 if (!matchUrl.startsWith("?")) { 2248 b.append("?"); 2249 } 2250 b.append(matchUrl); 2251 matchUrl = b.toString(); 2252 } 2253 2254 if (!myInMemoryResourceMatcher.canBeEvaluatedInMemory(matchUrl).supported()) { 2255 continue; 2256 } 2257 2258 int counter = 0; 2259 for (Map.Entry<IBaseResource, ResourceIndexedSearchParams> entries : 2260 resourceToIndexedParams.entrySet()) { 2261 ResourceIndexedSearchParams indexedParams = entries.getValue(); 2262 IBaseResource resource = entries.getKey(); 2263 2264 String resourceType = myContext.getResourceType(resource); 2265 if (!matchUrl.startsWith(resourceType + "?")) { 2266 continue; 2267 } 2268 2269 if (myInMemoryResourceMatcher 2270 .match(matchUrl, resource, indexedParams, theRequest) 2271 .matched()) { 2272 counter++; 2273 if (counter > 1) { 2274 throw new InvalidRequestException(Msg.code(542) + "Unable to process " + theActionName 2275 + " - Request would cause multiple resources to match URL: \"" + matchUrl 2276 + "\". Does transaction request contain duplicates?"); 2277 } 2278 } 2279 } 2280 } 2281 } 2282 } 2283 2284 protected abstract void flushSession(Map<IIdType, DaoMethodOutcome> theIdToPersistedOutcome); 2285 2286 private void validateResourcePresent(IBaseResource theResource, Integer theOrder, String theVerb) { 2287 if (theResource == null) { 2288 String msg = myContext 2289 .getLocalizer() 2290 .getMessage(BaseTransactionProcessor.class, "missingMandatoryResource", theVerb, theOrder); 2291 throw new InvalidRequestException(Msg.code(543) + msg); 2292 } 2293 } 2294 2295 private IIdType newIdType(String theResourceType, String theResourceId, String theVersion) { 2296 IdType id = new IdType(theResourceType, theResourceId, theVersion); 2297 return myContext.getVersion().newIdType().setValue(id.getValue()); 2298 } 2299 2300 private IIdType newIdType(String theToResourceName, String theIdPart) { 2301 return newIdType(theToResourceName, theIdPart, null); 2302 } 2303 2304 @VisibleForTesting 2305 public void setDaoRegistry(DaoRegistry theDaoRegistry) { 2306 myDaoRegistry = theDaoRegistry; 2307 } 2308 2309 private IFhirResourceDao getDaoOrThrowException(Class<? extends IBaseResource> theClass) { 2310 IFhirResourceDao<? extends IBaseResource> dao = myDaoRegistry.getResourceDaoOrNull(theClass); 2311 if (dao == null) { 2312 Set<String> types = new TreeSet<>(myDaoRegistry.getRegisteredDaoTypes()); 2313 String type = myContext.getResourceType(theClass); 2314 String msg = myContext 2315 .getLocalizer() 2316 .getMessage(BaseTransactionProcessor.class, "unsupportedResourceType", type, types.toString()); 2317 throw new InvalidRequestException(Msg.code(544) + msg); 2318 } 2319 return dao; 2320 } 2321 2322 private String toResourceName(Class<? extends IBaseResource> theResourceType) { 2323 return myContext.getResourceType(theResourceType); 2324 } 2325 2326 public void setContext(FhirContext theContext) { 2327 myContext = theContext; 2328 } 2329 2330 /** 2331 * Extracts the transaction url from the entry and verifies it's: 2332 * <li>not null or blank (unless it is a POST), and</li> 2333 * <li>is a relative url matching the resourceType it is about</li> 2334 * <p> 2335 * For POST requests, the url is allowed to be blank to preserve the existing behavior. 2336 * <p> 2337 * Returns the transaction url (or throws an InvalidRequestException if url is missing or not valid) 2338 */ 2339 private String extractAndVerifyTransactionUrlForEntry(IBase theEntry, String theVerb) { 2340 String url = extractTransactionUrlOrThrowException(theEntry, theVerb); 2341 if (url.isEmpty() || isValidResourceTypeUrl(url)) { 2342 // for POST requests, the url is allowed to be blank, which is checked in 2343 // extractTransactionUrlOrThrowException and an empty string is returned in that case. 2344 // That is why empty url is allowed here. 2345 return url; 2346 } 2347 2348 ourLog.debug("Invalid url. Should begin with a resource type: {}", url); 2349 String msg = myContext.getLocalizer().getMessage(BaseStorageDao.class, "transactionInvalidUrl", theVerb, url); 2350 throw new InvalidRequestException(Msg.code(2006) + msg); 2351 } 2352 2353 /** 2354 * Returns true if the provided url is a valid entry request.url. 2355 * <p> 2356 * This means: 2357 * a) not an absolute url (does not start with http/https) 2358 * b) starts with either a ResourceType or /ResourceType 2359 */ 2360 private boolean isValidResourceTypeUrl(@Nonnull String theUrl) { 2361 if (UrlUtil.isAbsolute(theUrl)) { 2362 return false; 2363 } else { 2364 int queryStringIndex = theUrl.indexOf("?"); 2365 String url; 2366 if (queryStringIndex > 0) { 2367 url = theUrl.substring(0, theUrl.indexOf("?")); 2368 } else { 2369 url = theUrl; 2370 } 2371 String[] parts; 2372 if (url.startsWith("/")) { 2373 parts = url.substring(1).split("/"); 2374 } else { 2375 parts = url.split("/"); 2376 } 2377 Set<String> allResourceTypes = myContext.getResourceTypes(); 2378 2379 return allResourceTypes.contains(parts[0]); 2380 } 2381 } 2382 2383 /** 2384 * Extracts the transaction url from the entry and verifies that it is not null/blank, unless it is a POST, 2385 * and returns it. For POST requests allows null or blank values to keep the existing behaviour and returns 2386 * an empty string in that case. 2387 */ 2388 private String extractTransactionUrlOrThrowException(IBase nextEntry, String verb) { 2389 String url = myVersionAdapter.getEntryRequestUrl(nextEntry); 2390 if ("POST".equals(verb) && isBlank(url)) { 2391 // allow blanks for POST to keep the existing behaviour 2392 // returning empty string instead of null to not get NPE later 2393 return ""; 2394 } 2395 2396 if (isBlank(url)) { 2397 throw new InvalidRequestException(Msg.code(545) 2398 + myContext.getLocalizer().getMessage(BaseStorageDao.class, "transactionMissingUrl", verb)); 2399 } 2400 return url; 2401 } 2402 2403 private IFhirResourceDao<? extends IBaseResource> toDao(UrlUtil.UrlParts theParts, String theVerb, String theUrl) { 2404 RuntimeResourceDefinition resType; 2405 try { 2406 resType = myContext.getResourceDefinition(theParts.getResourceType()); 2407 } catch (DataFormatException e) { 2408 String msg = 2409 myContext.getLocalizer().getMessage(BaseStorageDao.class, "transactionInvalidUrl", theVerb, theUrl); 2410 throw new InvalidRequestException(Msg.code(546) + msg); 2411 } 2412 IFhirResourceDao<? extends IBaseResource> dao = null; 2413 if (resType != null) { 2414 dao = myDaoRegistry.getResourceDao(resType.getImplementingClass()); 2415 } 2416 if (dao == null) { 2417 String msg = 2418 myContext.getLocalizer().getMessage(BaseStorageDao.class, "transactionInvalidUrl", theVerb, theUrl); 2419 throw new InvalidRequestException(Msg.code(547) + msg); 2420 } 2421 2422 return dao; 2423 } 2424 2425 private String toMatchUrl(IBase theEntry) { 2426 String verb = myVersionAdapter.getEntryRequestVerb(myContext, theEntry); 2427 switch (defaultString(verb)) { 2428 case POST: 2429 return myVersionAdapter.getEntryIfNoneExist(theEntry); 2430 case PUT: 2431 case DELETE: 2432 case PATCH: 2433 String url = extractTransactionUrlOrThrowException(theEntry, verb); 2434 UrlUtil.UrlParts parts = UrlUtil.parseUrl(url); 2435 if (isBlank(parts.getResourceId())) { 2436 return parts.getResourceType() + '?' + parts.getParams(); 2437 } 2438 return null; 2439 default: 2440 return null; 2441 } 2442 } 2443 2444 @VisibleForTesting 2445 public void setPartitionSettingsForUnitTest(PartitionSettings thePartitionSettings) { 2446 myPartitionSettings = thePartitionSettings; 2447 } 2448 2449 /** 2450 * Transaction Order, per the spec: 2451 * <p> 2452 * Process any DELETE interactions 2453 * Process any POST interactions 2454 * Process any PUT interactions 2455 * Process any PATCH interactions 2456 * Process any GET interactions 2457 */ 2458 // @formatter:off 2459 public class TransactionSorter implements Comparator<IBase> { 2460 2461 private final Set<String> myPlaceholderIds; 2462 2463 public TransactionSorter(Set<String> thePlaceholderIds) { 2464 myPlaceholderIds = thePlaceholderIds; 2465 } 2466 2467 @Override 2468 public int compare(IBase theO1, IBase theO2) { 2469 int o1 = toOrder(theO1); 2470 int o2 = toOrder(theO2); 2471 2472 if (o1 == o2) { 2473 String matchUrl1 = toMatchUrl(theO1); 2474 String matchUrl2 = toMatchUrl(theO2); 2475 if (isBlank(matchUrl1) && isBlank(matchUrl2)) { 2476 return 0; 2477 } 2478 if (isBlank(matchUrl1)) { 2479 return -1; 2480 } 2481 if (isBlank(matchUrl2)) { 2482 return 1; 2483 } 2484 2485 boolean match1containsSubstitutions = false; 2486 boolean match2containsSubstitutions = false; 2487 for (String nextPlaceholder : myPlaceholderIds) { 2488 if (matchUrl1.contains(nextPlaceholder)) { 2489 match1containsSubstitutions = true; 2490 } 2491 if (matchUrl2.contains(nextPlaceholder)) { 2492 match2containsSubstitutions = true; 2493 } 2494 } 2495 2496 if (match1containsSubstitutions && match2containsSubstitutions) { 2497 return 0; 2498 } 2499 if (!match1containsSubstitutions && !match2containsSubstitutions) { 2500 return 0; 2501 } 2502 if (match1containsSubstitutions) { 2503 return 1; 2504 } else { 2505 return -1; 2506 } 2507 } 2508 2509 return o1 - o2; 2510 } 2511 2512 private int toOrder(IBase theO1) { 2513 int o1 = 0; 2514 if (myVersionAdapter.getEntryRequestVerb(myContext, theO1) != null) { 2515 switch (myVersionAdapter.getEntryRequestVerb(myContext, theO1)) { 2516 case "DELETE": 2517 o1 = 1; 2518 break; 2519 case "POST": 2520 o1 = 2; 2521 break; 2522 case "PUT": 2523 o1 = 3; 2524 break; 2525 case "PATCH": 2526 o1 = 4; 2527 break; 2528 case "GET": 2529 o1 = 5; 2530 break; 2531 default: 2532 o1 = 0; 2533 break; 2534 } 2535 } 2536 return o1; 2537 } 2538 } 2539 2540 public class RetriableBundleTask implements Runnable { 2541 2542 private final CountDownLatch myCompletedLatch; 2543 private final RequestDetails myRequestDetails; 2544 private final IBase myNextReqEntry; 2545 private final Map<Integer, Object> myResponseMap; 2546 private final int myResponseOrder; 2547 private final boolean myNestedMode; 2548 private BaseServerResponseException myLastSeenException; 2549 2550 protected RetriableBundleTask( 2551 CountDownLatch theCompletedLatch, 2552 RequestDetails theRequestDetails, 2553 Map<Integer, Object> theResponseMap, 2554 int theResponseOrder, 2555 IBase theNextReqEntry, 2556 boolean theNestedMode) { 2557 this.myCompletedLatch = theCompletedLatch; 2558 this.myRequestDetails = theRequestDetails; 2559 this.myNextReqEntry = theNextReqEntry; 2560 this.myResponseMap = theResponseMap; 2561 this.myResponseOrder = theResponseOrder; 2562 this.myNestedMode = theNestedMode; 2563 this.myLastSeenException = null; 2564 } 2565 2566 private void processBatchEntry() { 2567 IBaseBundle subRequestBundle = 2568 myVersionAdapter.createBundle(org.hl7.fhir.r4.model.Bundle.BundleType.TRANSACTION.toCode()); 2569 myVersionAdapter.addEntry(subRequestBundle, myNextReqEntry); 2570 2571 IInterceptorBroadcaster compositeBroadcaster = 2572 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, myRequestDetails); 2573 2574 // Interceptor call: STORAGE_TRANSACTION_PROCESSING 2575 if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_TRANSACTION_PROCESSING)) { 2576 HookParams params = new HookParams() 2577 .add(RequestDetails.class, myRequestDetails) 2578 .addIfMatchesType(ServletRequestDetails.class, myRequestDetails) 2579 .add(IBaseBundle.class, subRequestBundle); 2580 compositeBroadcaster.callHooks(Pointcut.STORAGE_TRANSACTION_PROCESSING, params); 2581 } 2582 2583 IBaseBundle nextResponseBundle = processTransactionAsSubRequest( 2584 myRequestDetails, subRequestBundle, "Batch sub-request", myNestedMode); 2585 2586 IBase subResponseEntry = 2587 (IBase) myVersionAdapter.getEntries(nextResponseBundle).get(0); 2588 myResponseMap.put(myResponseOrder, subResponseEntry); 2589 2590 /* 2591 * If the individual entry didn't have a resource in its response, bring the sub-transaction's OperationOutcome across so the client can see it 2592 */ 2593 if (myVersionAdapter.getResource(subResponseEntry) == null) { 2594 IBase nextResponseBundleFirstEntry = 2595 (IBase) myVersionAdapter.getEntries(nextResponseBundle).get(0); 2596 myResponseMap.put(myResponseOrder, nextResponseBundleFirstEntry); 2597 } 2598 } 2599 2600 private boolean processBatchEntryWithRetry() { 2601 int maxAttempts = 3; 2602 for (int attempt = 1; ; attempt++) { 2603 try { 2604 processBatchEntry(); 2605 return true; 2606 } catch (BaseServerResponseException e) { 2607 // If we catch a known and structured exception from HAPI, just fail. 2608 myLastSeenException = e; 2609 return false; 2610 } catch (Throwable t) { 2611 myLastSeenException = new InternalErrorException(t); 2612 // If we have caught a non-tag-storage failure we are unfamiliar with, or we have exceeded max 2613 // attempts, exit. 2614 if (!DaoFailureUtil.isTagStorageFailure(t) || attempt >= maxAttempts) { 2615 ourLog.error("Failure during BATCH sub transaction processing", t); 2616 return false; 2617 } 2618 } 2619 } 2620 } 2621 2622 @Override 2623 public void run() { 2624 boolean success = processBatchEntryWithRetry(); 2625 if (!success) { 2626 populateResponseMapWithLastSeenException(); 2627 } 2628 2629 // checking for the parallelism 2630 ourLog.debug("processing batch for {} is completed", myVersionAdapter.getEntryRequestUrl(myNextReqEntry)); 2631 myCompletedLatch.countDown(); 2632 } 2633 2634 private void populateResponseMapWithLastSeenException() { 2635 ServerResponseExceptionHolder caughtEx = new ServerResponseExceptionHolder(); 2636 caughtEx.setException(myLastSeenException); 2637 myResponseMap.put(myResponseOrder, caughtEx); 2638 } 2639 } 2640 2641 private static class ServerResponseExceptionHolder { 2642 private BaseServerResponseException myException; 2643 2644 public BaseServerResponseException getException() { 2645 return myException; 2646 } 2647 2648 public void setException(BaseServerResponseException myException) { 2649 this.myException = myException; 2650 } 2651 } 2652 2653 public static boolean isPlaceholder(IIdType theId) { 2654 if (theId != null && theId.getValue() != null) { 2655 return theId.getValue().startsWith("urn:oid:") || theId.getValue().startsWith("urn:uuid:"); 2656 } 2657 return false; 2658 } 2659 2660 public static String toStatusString(int theStatusCode) { 2661 return theStatusCode + " " + defaultString(Constants.HTTP_STATUS_NAMES.get(theStatusCode)); 2662 } 2663 2664 /** 2665 * Given a match URL containing 2666 * 2667 * @param theIdSubstitutions 2668 * @param theMatchUrl 2669 * @return 2670 */ 2671 public static String performIdSubstitutionsInMatchUrl(IdSubstitutionMap theIdSubstitutions, String theMatchUrl) { 2672 String matchUrl = theMatchUrl; 2673 if (isNotBlank(matchUrl) && !theIdSubstitutions.isEmpty()) { 2674 int startIdx = 0; 2675 while (startIdx != -1) { 2676 2677 int endIdx = matchUrl.indexOf('&', startIdx + 1); 2678 if (endIdx == -1) { 2679 endIdx = matchUrl.length(); 2680 } 2681 2682 int equalsIdx = matchUrl.indexOf('=', startIdx + 1); 2683 2684 int searchFrom; 2685 if (equalsIdx == -1) { 2686 searchFrom = matchUrl.length(); 2687 } else if (equalsIdx >= endIdx) { 2688 // First equals we found is from a subsequent parameter 2689 searchFrom = matchUrl.length(); 2690 } else { 2691 String paramValue = matchUrl.substring(equalsIdx + 1, endIdx); 2692 boolean isUrn = isUrn(paramValue); 2693 boolean isUrnEscaped = !isUrn && isUrnEscaped(paramValue); 2694 if (isUrn || isUrnEscaped) { 2695 if (isUrnEscaped) { 2696 paramValue = UrlUtil.unescape(paramValue); 2697 } 2698 IIdType replacement = theIdSubstitutions.getForSource(paramValue); 2699 if (replacement != null) { 2700 String replacementValue; 2701 if (replacement.hasVersionIdPart()) { 2702 replacementValue = replacement.toVersionless().getValue(); 2703 } else { 2704 replacementValue = replacement.getValue(); 2705 } 2706 matchUrl = matchUrl.substring(0, equalsIdx + 1) 2707 + replacementValue 2708 + matchUrl.substring(endIdx); 2709 searchFrom = equalsIdx + 1 + replacementValue.length(); 2710 } else { 2711 searchFrom = endIdx; 2712 } 2713 } else { 2714 searchFrom = endIdx; 2715 } 2716 } 2717 2718 if (searchFrom >= matchUrl.length()) { 2719 break; 2720 } 2721 2722 startIdx = matchUrl.indexOf('&', searchFrom); 2723 } 2724 } 2725 return matchUrl; 2726 } 2727 2728 private static boolean isUrn(@Nonnull String theId) { 2729 return theId.startsWith(URN_PREFIX); 2730 } 2731 2732 private static boolean isUrnEscaped(@Nonnull String theId) { 2733 return theId.startsWith(URN_PREFIX_ESCAPED); 2734 } 2735}