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 theConditionalUrl, 126 match.size()); 127 throw new PreconditionFailedException(Msg.code(972) + msg); 128 } else if (match.size() == 1) { 129 IResourcePersistentId pid = match.iterator().next(); 130 entityToUpdate = readEntityLatestVersion(pid, theRequestDetails, theTransactionDetails); 131 resourceId = entityToUpdate.getIdDt(); 132 } else { 133 String msg = getContext() 134 .getLocalizer() 135 .getMessageSanitized(BaseStorageDao.class, "invalidMatchUrlNoMatches", theConditionalUrl); 136 throw new ResourceNotFoundException(Msg.code(973) + msg); 137 } 138 139 } else { 140 resourceId = theId; 141 entityToUpdate = readEntityLatestVersion(theId, theRequestDetails, theTransactionDetails); 142 if (theId.hasVersionIdPart()) { 143 if (theId.getVersionIdPartAsLong() != entityToUpdate.getVersion()) { 144 throw new ResourceVersionConflictException(Msg.code(974) + "Version " + theId.getVersionIdPart() 145 + " is not the most recent version of this resource, unable to apply patch"); 146 } 147 } 148 } 149 150 validateResourceType(entityToUpdate, getResourceName()); 151 152 if (entityToUpdate.isDeleted()) { 153 throw createResourceGoneException(entityToUpdate); 154 } 155 156 IBaseResource resourceToUpdate = getStorageResourceParser().toResource(entityToUpdate, false); 157 if (resourceToUpdate == null) { 158 // If this is null, we are presumably in a FHIR transaction bundle with both a create and a patch on the 159 // same 160 // resource. This is weird but not impossible. 161 resourceToUpdate = theTransactionDetails.getResolvedResource(resourceId); 162 } 163 164 IBaseResource destination; 165 switch (thePatchType) { 166 case JSON_PATCH: 167 destination = JsonPatchUtils.apply(getContext(), resourceToUpdate, thePatchBody); 168 break; 169 case XML_PATCH: 170 destination = XmlPatchUtils.apply(getContext(), resourceToUpdate, thePatchBody); 171 break; 172 case FHIR_PATCH_XML: 173 case FHIR_PATCH_JSON: 174 default: 175 IBaseParameters fhirPatchJson = theFhirPatchBody; 176 new FhirPatch(getContext()).apply(resourceToUpdate, fhirPatchJson); 177 destination = resourceToUpdate; 178 break; 179 } 180 181 @SuppressWarnings("unchecked") 182 T destinationCasted = (T) destination; 183 myFhirContext 184 .newJsonParser() 185 .setParserErrorHandler(STRICT_ERROR_HANDLER) 186 .encodeResourceToString(destinationCasted); 187 188 preProcessResourceForStorage(destinationCasted, theRequestDetails, theTransactionDetails, true); 189 190 return doUpdateForUpdateOrPatch( 191 theRequestDetails, 192 resourceId, 193 theConditionalUrl, 194 thePerformIndexing, 195 false, 196 destinationCasted, 197 entityToUpdate, 198 RestOperationTypeEnum.PATCH, 199 theTransactionDetails); 200 } 201 202 @Override 203 @Nonnull 204 public abstract Class<T> getResourceType(); 205 206 @Override 207 @Nonnull 208 protected abstract String getResourceName(); 209 210 protected abstract IBasePersistedResource readEntityLatestVersion( 211 IResourcePersistentId thePersistentId, 212 RequestDetails theRequestDetails, 213 TransactionDetails theTransactionDetails); 214 215 protected abstract IBasePersistedResource readEntityLatestVersion( 216 IIdType theId, RequestDetails theRequestDetails, TransactionDetails theTransactionDetails); 217 218 protected DaoMethodOutcome doUpdateForUpdateOrPatch( 219 RequestDetails theRequest, 220 IIdType theResourceId, 221 String theMatchUrl, 222 boolean thePerformIndexing, 223 boolean theForceUpdateVersion, 224 T theResource, 225 IBasePersistedResource theEntity, 226 RestOperationTypeEnum theOperationType, 227 TransactionDetails theTransactionDetails) { 228 if (theResourceId.hasVersionIdPart() 229 && Long.parseLong(theResourceId.getVersionIdPart()) != theEntity.getVersion()) { 230 throw new ResourceVersionConflictException( 231 Msg.code(989) + "Trying to update " + theResourceId + " but this is not the current version"); 232 } 233 234 if (theResourceId.hasResourceType() && !theResourceId.getResourceType().equals(getResourceName())) { 235 throw new UnprocessableEntityException(Msg.code(990) + "Invalid resource ID[" 236 + theEntity.getIdDt().toUnqualifiedVersionless() + "] of type[" + theEntity.getResourceType() 237 + "] - Does not match expected [" + getResourceName() + "]"); 238 } 239 240 IBaseResource oldResource; 241 if (getStorageSettings().isMassIngestionMode()) { 242 oldResource = null; 243 } else { 244 oldResource = getStorageResourceParser().toResource(theEntity, false); 245 } 246 247 /* 248 * Mark the entity as not deleted - This is also done in the actual updateInternal() 249 * method later on so it usually doesn't matter whether we do it here, but in the 250 * case of a transaction with multiple PUTs we don't get there until later so 251 * having this here means that a transaction can have a reference in one 252 * resource to another resource in the same transaction that is being 253 * un-deleted by the transaction. Wacky use case, sure. But it's real. 254 * 255 * See SystemProviderR4Test#testTransactionReSavesPreviouslyDeletedResources 256 * for a test that needs this. 257 */ 258 boolean wasDeleted = theEntity.isDeleted(); 259 theEntity.setNotDeleted(); 260 261 /* 262 * If we aren't indexing, that means we're doing this inside a transaction. 263 * The transaction will do the actual storage to the database a bit later on, 264 * after placeholder IDs have been replaced, by calling {@link #updateInternal} 265 * directly. So we just bail now. 266 */ 267 if (!thePerformIndexing) { 268 theResource.setId(theEntity.getIdDt().getValue()); 269 DaoMethodOutcome outcome = toMethodOutcome( 270 theRequest, theEntity, theResource, theMatchUrl, theOperationType) 271 .setCreated(wasDeleted); 272 outcome.setPreviousResource(oldResource); 273 if (!outcome.isNop()) { 274 // Technically this may not end up being right since we might not increment if the 275 // contents turn out to be the same 276 outcome.setId(outcome.getId() 277 .withVersion(Long.toString(outcome.getId().getVersionIdPartAsLong() + 1))); 278 } 279 return outcome; 280 } 281 282 /* 283 * Otherwise, we're not in a transaction 284 */ 285 return updateInternal( 286 theRequest, 287 theResource, 288 theMatchUrl, 289 thePerformIndexing, 290 theForceUpdateVersion, 291 theEntity, 292 theResourceId, 293 oldResource, 294 theOperationType, 295 theTransactionDetails); 296 } 297 298 public static void validateResourceType(IBasePersistedResource theEntity, String theResourceName) { 299 if (!theResourceName.equals(theEntity.getResourceType())) { 300 throw new ResourceNotFoundException(Msg.code(935) + "Resource with ID " 301 + theEntity.getIdDt().getIdPart() + " exists but it is not of type " + theResourceName 302 + ", found resource of type " + theEntity.getResourceType()); 303 } 304 } 305 306 protected DeleteMethodOutcome deleteExpunge(String theUrl, RequestDetails theRequest) { 307 if (!getStorageSettings().canDeleteExpunge()) { 308 throw new MethodNotAllowedException(Msg.code(963) + "_expunge is not enabled on this server: " 309 + getStorageSettings().cannotDeleteExpungeReason()); 310 } 311 312 RestfulServerUtils.DeleteCascadeDetails cascadeDelete = 313 RestfulServerUtils.extractDeleteCascadeParameter(theRequest); 314 boolean cascade = false; 315 Integer cascadeMaxRounds = null; 316 if (cascadeDelete.getMode() == DeleteCascadeModeEnum.DELETE) { 317 cascade = true; 318 cascadeMaxRounds = cascadeDelete.getMaxRounds(); 319 if (cascadeMaxRounds == null) { 320 cascadeMaxRounds = myStorageSettings.getMaximumDeleteConflictQueryCount(); 321 } else if (myStorageSettings.getMaximumDeleteConflictQueryCount() != null 322 && myStorageSettings.getMaximumDeleteConflictQueryCount() < cascadeMaxRounds) { 323 cascadeMaxRounds = myStorageSettings.getMaximumDeleteConflictQueryCount(); 324 } 325 } 326 327 List<String> urlsToDeleteExpunge = Collections.singletonList(theUrl); 328 try { 329 String jobId = getDeleteExpungeJobSubmitter() 330 .submitJob( 331 getStorageSettings().getExpungeBatchSize(), 332 urlsToDeleteExpunge, 333 cascade, 334 cascadeMaxRounds, 335 theRequest); 336 return new DeleteMethodOutcome(createInfoOperationOutcome("Delete job submitted with id " + jobId)); 337 } catch (InvalidRequestException e) { 338 throw new InvalidRequestException(Msg.code(965) + "Invalid Delete Expunge Request: " + e.getMessage(), e); 339 } 340 } 341}