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