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}