001/*- 002 * #%L 003 * HAPI FHIR JPA Server 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.interceptor; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.interceptor.api.Hook; 024import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster; 025import ca.uhn.fhir.interceptor.api.Interceptor; 026import ca.uhn.fhir.interceptor.api.Pointcut; 027import ca.uhn.fhir.jpa.api.dao.DaoRegistry; 028import ca.uhn.fhir.jpa.api.model.DeleteConflictList; 029import ca.uhn.fhir.jpa.delete.DeleteConflictOutcome; 030import ca.uhn.fhir.jpa.delete.ThreadSafeResourceDeleterSvc; 031import ca.uhn.fhir.rest.api.DeleteCascadeModeEnum; 032import ca.uhn.fhir.rest.api.server.RequestDetails; 033import ca.uhn.fhir.rest.api.server.ResponseDetails; 034import ca.uhn.fhir.rest.api.server.storage.TransactionDetails; 035import ca.uhn.fhir.rest.server.RestfulServerUtils; 036import ca.uhn.fhir.util.OperationOutcomeUtil; 037import jakarta.annotation.Nonnull; 038import jakarta.annotation.Nullable; 039import org.apache.commons.lang3.Validate; 040import org.hl7.fhir.instance.model.api.IBaseOperationOutcome; 041import org.hl7.fhir.instance.model.api.IBaseResource; 042import org.hl7.fhir.r4.model.OperationOutcome; 043import org.slf4j.Logger; 044import org.slf4j.LoggerFactory; 045 046import java.util.ArrayList; 047import java.util.List; 048 049import static ca.uhn.fhir.jpa.delete.DeleteConflictService.MAX_RETRY_ATTEMPTS; 050import static org.apache.commons.lang3.StringUtils.isNotBlank; 051 052/** 053 * Interceptor that allows for cascading deletes (deletes that resolve constraint issues). 054 * <p> 055 * For example, if <code>DiagnosticReport/A</code> has a reference to <code>Observation/B</code> 056 * it is not normally possible to delete <code>Observation/B</code> without first deleting 057 * <code>DiagnosticReport/A</code>. With this interceptor in place, it is. 058 * </p> 059 * <p> 060 * When using this interceptor, client requests must include the parameter 061 * <code>_cascade=delete</code> on the DELETE URL in order to activate 062 * cascading delete, or include the request header <code>X-Cascade-Delete: delete</code> 063 * </p> 064 */ 065@Interceptor 066public class CascadingDeleteInterceptor { 067 068 /* 069 * We keep the orders for the various handlers of {@link Pointcut#STORAGE_PRESTORAGE_DELETE_CONFLICTS} in one place 070 * so it's easy to compare them 071 */ 072 public static final int OVERRIDE_PATH_BASED_REF_INTEGRITY_INTERCEPTOR_ORDER = 0; 073 public static final int CASCADING_DELETE_INTERCEPTOR_ORDER = 1; 074 075 private static final Logger ourLog = LoggerFactory.getLogger(CascadingDeleteInterceptor.class); 076 private static final String CASCADED_DELETES_KEY = 077 CascadingDeleteInterceptor.class.getName() + "_CASCADED_DELETES_KEY"; 078 private static final String CASCADED_DELETES_FAILED_KEY = 079 CascadingDeleteInterceptor.class.getName() + "_CASCADED_DELETES_FAILED_KEY"; 080 081 private final DaoRegistry myDaoRegistry; 082 private final IInterceptorBroadcaster myInterceptorBroadcaster; 083 private final FhirContext myFhirContext; 084 private final ThreadSafeResourceDeleterSvc myThreadSafeResourceDeleterSvc; 085 086 /** 087 * Constructor 088 * 089 * @param theDaoRegistry The DAO registry (must not be null) 090 */ 091 public CascadingDeleteInterceptor( 092 @Nonnull FhirContext theFhirContext, 093 @Nonnull DaoRegistry theDaoRegistry, 094 @Nonnull IInterceptorBroadcaster theInterceptorBroadcaster, 095 @Nonnull ThreadSafeResourceDeleterSvc theThreadSafeResourceDeleterSvc) { 096 Validate.notNull(theDaoRegistry, "theDaoRegistry must not be null"); 097 Validate.notNull(theInterceptorBroadcaster, "theInterceptorBroadcaster must not be null"); 098 Validate.notNull(theFhirContext, "theFhirContext must not be null"); 099 Validate.notNull(theThreadSafeResourceDeleterSvc, "theSafeDeleter must not be null"); 100 101 myDaoRegistry = theDaoRegistry; 102 myInterceptorBroadcaster = theInterceptorBroadcaster; 103 myFhirContext = theFhirContext; 104 myThreadSafeResourceDeleterSvc = theThreadSafeResourceDeleterSvc; 105 } 106 107 @Hook(value = Pointcut.STORAGE_PRESTORAGE_DELETE_CONFLICTS, order = CASCADING_DELETE_INTERCEPTOR_ORDER) 108 public DeleteConflictOutcome handleDeleteConflicts( 109 DeleteConflictList theConflictList, RequestDetails theRequest, TransactionDetails theTransactionDetails) { 110 ourLog.debug("Have delete conflicts: {}", theConflictList); 111 112 if (shouldCascade(theRequest) == DeleteCascadeModeEnum.NONE) { 113 114 // Add a message to the response 115 String message = myFhirContext.getLocalizer().getMessage(CascadingDeleteInterceptor.class, "noParam"); 116 ourLog.trace(message); 117 118 if (theRequest != null) { 119 theRequest.getUserData().put(CASCADED_DELETES_FAILED_KEY, message); 120 } 121 122 return null; 123 } 124 125 myThreadSafeResourceDeleterSvc.delete(theRequest, theConflictList, theTransactionDetails); 126 127 return new DeleteConflictOutcome().setShouldRetryCount(MAX_RETRY_ATTEMPTS); 128 } 129 130 public static List<String> getCascadedDeletesList(RequestDetails theRequest, boolean theCreate) { 131 List<String> retVal = (List<String>) theRequest.getUserData().get(CASCADED_DELETES_KEY); 132 if (retVal == null && theCreate) { 133 retVal = new ArrayList<>(); 134 theRequest.getUserData().put(CASCADED_DELETES_KEY, retVal); 135 } 136 return retVal; 137 } 138 139 @Hook(Pointcut.SERVER_OUTGOING_FAILURE_OPERATIONOUTCOME) 140 public void outgoingFailureOperationOutcome(RequestDetails theRequestDetails, IBaseOperationOutcome theResponse) { 141 if (theRequestDetails != null) { 142 143 String failedDeleteMessage = 144 (String) theRequestDetails.getUserData().get(CASCADED_DELETES_FAILED_KEY); 145 if (isNotBlank(failedDeleteMessage)) { 146 FhirContext ctx = theRequestDetails.getFhirContext(); 147 String severity = OperationOutcome.IssueSeverity.INFORMATION.toCode(); 148 String code = OperationOutcome.IssueType.INFORMATIONAL.toCode(); 149 String details = failedDeleteMessage; 150 OperationOutcomeUtil.addIssue(ctx, theResponse, severity, details, null, code); 151 } 152 } 153 } 154 155 @Hook(Pointcut.SERVER_OUTGOING_RESPONSE) 156 public void outgoingResponse( 157 RequestDetails theRequestDetails, ResponseDetails theResponseDetails, IBaseResource theResponse) { 158 if (theRequestDetails != null) { 159 160 // Successful delete list 161 List<String> deleteList = getCascadedDeletesList(theRequestDetails, false); 162 if (deleteList != null) { 163 if (theResponseDetails.getResponseCode() == 200) { 164 if (theResponse instanceof IBaseOperationOutcome) { 165 FhirContext ctx = theRequestDetails.getFhirContext(); 166 IBaseOperationOutcome oo = (IBaseOperationOutcome) theResponse; 167 String severity = OperationOutcome.IssueSeverity.INFORMATION.toCode(); 168 String code = OperationOutcome.IssueType.INFORMATIONAL.toCode(); 169 String details = ctx.getLocalizer() 170 .getMessage( 171 CascadingDeleteInterceptor.class, "successMsg", deleteList.size(), deleteList); 172 OperationOutcomeUtil.addIssue(ctx, oo, severity, details, null, code); 173 } 174 } 175 } 176 } 177 } 178 179 /** 180 * Subclasses may override 181 * 182 * @param theRequest The REST request (may be null) 183 * @return Returns true if cascading delete should be allowed 184 */ 185 @Nonnull 186 protected DeleteCascadeModeEnum shouldCascade(@Nullable RequestDetails theRequest) { 187 return RestfulServerUtils.extractDeleteCascadeParameter(theRequest).getMode(); 188 } 189}