001/*- 002 * #%L 003 * HAPI FHIR Storage api 004 * %% 005 * Copyright (C) 2014 - 2024 Smile CDR, Inc. 006 * %% 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * You may obtain a copy of the License at 010 * 011 * http://www.apache.org/licenses/LICENSE-2.0 012 * 013 * Unless required by applicable law or agreed to in writing, software 014 * distributed under the License is distributed on an "AS IS" BASIS, 015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 016 * See the License for the specific language governing permissions and 017 * limitations under the License. 018 * #L% 019 */ 020package ca.uhn.fhir.jpa.dao; 021 022import ca.uhn.fhir.i18n.Msg; 023import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; 024import ca.uhn.fhir.jpa.api.dao.IJpaDao; 025import ca.uhn.fhir.jpa.api.model.DaoMethodOutcome; 026import ca.uhn.fhir.jpa.api.model.DeleteMethodOutcome; 027import ca.uhn.fhir.jpa.dao.tx.HapiTransactionService; 028import ca.uhn.fhir.jpa.model.cross.IBasePersistedResource; 029import ca.uhn.fhir.jpa.patch.FhirPatch; 030import ca.uhn.fhir.jpa.patch.JsonPatchUtils; 031import ca.uhn.fhir.jpa.patch.XmlPatchUtils; 032import ca.uhn.fhir.parser.StrictErrorHandler; 033import ca.uhn.fhir.rest.api.DeleteCascadeModeEnum; 034import ca.uhn.fhir.rest.api.PatchTypeEnum; 035import ca.uhn.fhir.rest.api.RestOperationTypeEnum; 036import ca.uhn.fhir.rest.api.server.RequestDetails; 037import ca.uhn.fhir.rest.api.server.storage.IDeleteExpungeJobSubmitter; 038import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId; 039import ca.uhn.fhir.rest.api.server.storage.TransactionDetails; 040import ca.uhn.fhir.rest.server.RestfulServerUtils; 041import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; 042import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException; 043import ca.uhn.fhir.rest.server.exceptions.PreconditionFailedException; 044import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException; 045import ca.uhn.fhir.rest.server.exceptions.ResourceVersionConflictException; 046import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; 047import jakarta.annotation.Nonnull; 048import org.hl7.fhir.instance.model.api.IBaseParameters; 049import org.hl7.fhir.instance.model.api.IBaseResource; 050import org.hl7.fhir.instance.model.api.IIdType; 051import org.springframework.beans.factory.annotation.Autowired; 052import org.springframework.transaction.support.TransactionSynchronizationManager; 053 054import java.util.Collections; 055import java.util.List; 056import java.util.Set; 057 058import static org.apache.commons.lang3.StringUtils.isNotBlank; 059 060public abstract class BaseStorageResourceDao<T extends IBaseResource> extends BaseStorageDao 061 implements IFhirResourceDao<T>, IJpaDao<T> { 062 public static final StrictErrorHandler STRICT_ERROR_HANDLER = new StrictErrorHandler(); 063 064 @Autowired 065 protected abstract HapiTransactionService getTransactionService(); 066 067 @Autowired 068 protected abstract MatchResourceUrlService getMatchResourceUrlService(); 069 070 @Autowired 071 protected abstract IStorageResourceParser getStorageResourceParser(); 072 073 @Autowired 074 protected abstract IDeleteExpungeJobSubmitter getDeleteExpungeJobSubmitter(); 075 076 @Override 077 public DaoMethodOutcome patch( 078 IIdType theId, 079 String theConditionalUrl, 080 PatchTypeEnum thePatchType, 081 String thePatchBody, 082 IBaseParameters theFhirPatchBody, 083 RequestDetails theRequestDetails) { 084 TransactionDetails transactionDetails = new TransactionDetails(); 085 return getTransactionService() 086 .execute( 087 theRequestDetails, 088 transactionDetails, 089 tx -> patchInTransaction( 090 theId, 091 theConditionalUrl, 092 true, 093 thePatchType, 094 thePatchBody, 095 theFhirPatchBody, 096 theRequestDetails, 097 transactionDetails)); 098 } 099 100 @Override 101 public DaoMethodOutcome patchInTransaction( 102 IIdType theId, 103 String theConditionalUrl, 104 boolean thePerformIndexing, 105 PatchTypeEnum thePatchType, 106 String thePatchBody, 107 IBaseParameters theFhirPatchBody, 108 RequestDetails theRequestDetails, 109 TransactionDetails theTransactionDetails) { 110 assert TransactionSynchronizationManager.isActualTransactionActive(); 111 112 IBasePersistedResource entityToUpdate; 113 IIdType resourceId; 114 if (isNotBlank(theConditionalUrl)) { 115 116 Set<IResourcePersistentId> match = getMatchResourceUrlService() 117 .processMatchUrl(theConditionalUrl, getResourceType(), theTransactionDetails, theRequestDetails); 118 if (match.size() > 1) { 119 String msg = getContext() 120 .getLocalizer() 121 .getMessageSanitized( 122 BaseStorageDao.class, 123 "transactionOperationWithMultipleMatchFailure", 124 "PATCH", 125 getResourceName(), 126 theConditionalUrl, 127 match.size()); 128 throw new PreconditionFailedException(Msg.code(972) + msg); 129 } else if (match.size() == 1) { 130 IResourcePersistentId pid = match.iterator().next(); 131 entityToUpdate = readEntityLatestVersion(pid, theRequestDetails, theTransactionDetails); 132 resourceId = entityToUpdate.getIdDt(); 133 } else { 134 String msg = getContext() 135 .getLocalizer() 136 .getMessageSanitized(BaseStorageDao.class, "invalidMatchUrlNoMatches", theConditionalUrl); 137 throw new ResourceNotFoundException(Msg.code(973) + msg); 138 } 139 140 } else { 141 resourceId = theId; 142 entityToUpdate = readEntityLatestVersion(theId, theRequestDetails, theTransactionDetails); 143 if (theId.hasVersionIdPart()) { 144 if (theId.getVersionIdPartAsLong() != entityToUpdate.getVersion()) { 145 throw new ResourceVersionConflictException(Msg.code(974) + "Version " + theId.getVersionIdPart() 146 + " is not the most recent version of this resource, unable to apply patch"); 147 } 148 } 149 } 150 151 validateResourceType(entityToUpdate, getResourceName()); 152 153 if (entityToUpdate.isDeleted()) { 154 throw createResourceGoneException(entityToUpdate); 155 } 156 157 IBaseResource resourceToUpdate = getStorageResourceParser().toResource(entityToUpdate, false); 158 if (resourceToUpdate == null) { 159 // If this is null, we are presumably in a FHIR transaction bundle with both a create and a patch on the 160 // same 161 // resource. This is weird but not impossible. 162 resourceToUpdate = theTransactionDetails.getResolvedResource(resourceId); 163 } 164 165 IBaseResource destination; 166 switch (thePatchType) { 167 case JSON_PATCH: 168 destination = JsonPatchUtils.apply(getContext(), resourceToUpdate, thePatchBody); 169 break; 170 case XML_PATCH: 171 destination = XmlPatchUtils.apply(getContext(), resourceToUpdate, thePatchBody); 172 break; 173 case FHIR_PATCH_XML: 174 case FHIR_PATCH_JSON: 175 default: 176 IBaseParameters fhirPatchJson = theFhirPatchBody; 177 new FhirPatch(getContext()).apply(resourceToUpdate, fhirPatchJson); 178 destination = resourceToUpdate; 179 break; 180 } 181 182 @SuppressWarnings("unchecked") 183 T destinationCasted = (T) destination; 184 myFhirContext 185 .newJsonParser() 186 .setParserErrorHandler(STRICT_ERROR_HANDLER) 187 .encodeResourceToString(destinationCasted); 188 189 preProcessResourceForStorage(destinationCasted, theRequestDetails, theTransactionDetails, true); 190 191 return doUpdateForUpdateOrPatch( 192 theRequestDetails, 193 resourceId, 194 theConditionalUrl, 195 thePerformIndexing, 196 false, 197 destinationCasted, 198 entityToUpdate, 199 RestOperationTypeEnum.PATCH, 200 theTransactionDetails); 201 } 202 203 @Override 204 @Nonnull 205 public abstract Class<T> getResourceType(); 206 207 @Override 208 @Nonnull 209 protected abstract String getResourceName(); 210 211 protected abstract IBasePersistedResource readEntityLatestVersion( 212 IResourcePersistentId thePersistentId, 213 RequestDetails theRequestDetails, 214 TransactionDetails theTransactionDetails); 215 216 protected abstract IBasePersistedResource readEntityLatestVersion( 217 IIdType theId, RequestDetails theRequestDetails, TransactionDetails theTransactionDetails); 218 219 protected DaoMethodOutcome doUpdateForUpdateOrPatch( 220 RequestDetails theRequest, 221 IIdType theResourceId, 222 String theMatchUrl, 223 boolean thePerformIndexing, 224 boolean theForceUpdateVersion, 225 T theResource, 226 IBasePersistedResource theEntity, 227 RestOperationTypeEnum theOperationType, 228 TransactionDetails theTransactionDetails) { 229 if (theResourceId.hasVersionIdPart() 230 && Long.parseLong(theResourceId.getVersionIdPart()) != theEntity.getVersion()) { 231 throw new ResourceVersionConflictException( 232 Msg.code(989) + "Trying to update " + theResourceId + " but this is not the current version"); 233 } 234 235 if (theResourceId.hasResourceType() && !theResourceId.getResourceType().equals(getResourceName())) { 236 throw new UnprocessableEntityException(Msg.code(990) + "Invalid resource ID[" 237 + theEntity.getIdDt().toUnqualifiedVersionless() + "] of type[" + theEntity.getResourceType() 238 + "] - Does not match expected [" + getResourceName() + "]"); 239 } 240 241 IBaseResource oldResource; 242 if (getStorageSettings().isMassIngestionMode()) { 243 oldResource = null; 244 } else { 245 oldResource = getStorageResourceParser().toResource(theEntity, false); 246 } 247 248 /* 249 * Mark the entity as not deleted - This is also done in the actual updateInternal() 250 * method later on so it usually doesn't matter whether we do it here, but in the 251 * case of a transaction with multiple PUTs we don't get there until later so 252 * having this here means that a transaction can have a reference in one 253 * resource to another resource in the same transaction that is being 254 * un-deleted by the transaction. Wacky use case, sure. But it's real. 255 * 256 * See SystemProviderR4Test#testTransactionReSavesPreviouslyDeletedResources 257 * for a test that needs this. 258 */ 259 boolean wasDeleted = theEntity.isDeleted(); 260 theEntity.setNotDeleted(); 261 262 /* 263 * If we aren't indexing, that means we're doing this inside a transaction. 264 * The transaction will do the actual storage to the database a bit later on, 265 * after placeholder IDs have been replaced, by calling {@link #updateInternal} 266 * directly. So we just bail now. 267 */ 268 if (!thePerformIndexing) { 269 theResource.setId(theEntity.getIdDt().getValue()); 270 DaoMethodOutcome outcome = toMethodOutcome( 271 theRequest, theEntity, theResource, theMatchUrl, theOperationType) 272 .setCreated(wasDeleted); 273 outcome.setPreviousResource(oldResource); 274 if (!outcome.isNop()) { 275 // Technically this may not end up being right since we might not increment if the 276 // contents turn out to be the same 277 outcome.setId(outcome.getId() 278 .withVersion(Long.toString(outcome.getId().getVersionIdPartAsLong() + 1))); 279 } 280 return outcome; 281 } 282 283 /* 284 * Otherwise, we're not in a transaction 285 */ 286 return updateInternal( 287 theRequest, 288 theResource, 289 theMatchUrl, 290 thePerformIndexing, 291 theForceUpdateVersion, 292 theEntity, 293 theResourceId, 294 oldResource, 295 theOperationType, 296 theTransactionDetails); 297 } 298 299 public static void validateResourceType(IBasePersistedResource theEntity, String theResourceName) { 300 if (!theResourceName.equals(theEntity.getResourceType())) { 301 throw new ResourceNotFoundException(Msg.code(935) + "Resource with ID " 302 + theEntity.getIdDt().getIdPart() + " exists but it is not of type " + theResourceName 303 + ", found resource of type " + theEntity.getResourceType()); 304 } 305 } 306 307 protected DeleteMethodOutcome deleteExpunge(String theUrl, RequestDetails theRequest) { 308 if (!getStorageSettings().canDeleteExpunge()) { 309 throw new MethodNotAllowedException(Msg.code(963) + "_expunge is not enabled on this server: " 310 + getStorageSettings().cannotDeleteExpungeReason()); 311 } 312 313 RestfulServerUtils.DeleteCascadeDetails cascadeDelete = 314 RestfulServerUtils.extractDeleteCascadeParameter(theRequest); 315 boolean cascade = false; 316 Integer cascadeMaxRounds = null; 317 if (cascadeDelete.getMode() == DeleteCascadeModeEnum.DELETE) { 318 cascade = true; 319 cascadeMaxRounds = cascadeDelete.getMaxRounds(); 320 if (cascadeMaxRounds == null) { 321 cascadeMaxRounds = myStorageSettings.getMaximumDeleteConflictQueryCount(); 322 } else if (myStorageSettings.getMaximumDeleteConflictQueryCount() != null 323 && myStorageSettings.getMaximumDeleteConflictQueryCount() < cascadeMaxRounds) { 324 cascadeMaxRounds = myStorageSettings.getMaximumDeleteConflictQueryCount(); 325 } 326 } 327 328 List<String> urlsToDeleteExpunge = Collections.singletonList(theUrl); 329 try { 330 String jobId = getDeleteExpungeJobSubmitter() 331 .submitJob( 332 getStorageSettings().getExpungeBatchSize(), 333 urlsToDeleteExpunge, 334 cascade, 335 cascadeMaxRounds, 336 theRequest); 337 return new DeleteMethodOutcome(createInfoOperationOutcome("Delete job submitted with id " + jobId)); 338 } catch (InvalidRequestException e) { 339 throw new InvalidRequestException(Msg.code(965) + "Invalid Delete Expunge Request: " + e.getMessage(), e); 340 } 341 } 342}