001/*
002 * #%L
003 * HAPI FHIR - Server Framework
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.rest.server.interceptor;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.i18n.Msg;
024import ca.uhn.fhir.interceptor.api.Hook;
025import ca.uhn.fhir.interceptor.api.Interceptor;
026import ca.uhn.fhir.interceptor.api.Pointcut;
027import ca.uhn.fhir.parser.DataFormatException;
028import ca.uhn.fhir.rest.api.Constants;
029import ca.uhn.fhir.rest.api.SummaryEnum;
030import ca.uhn.fhir.rest.api.server.IRestfulResponse;
031import ca.uhn.fhir.rest.api.server.RequestDetails;
032import ca.uhn.fhir.rest.api.server.ResponseDetails;
033import ca.uhn.fhir.rest.server.RestfulServerUtils;
034import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
035import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
036import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
037import ca.uhn.fhir.rest.server.method.BaseResourceReturningMethodBinding;
038import ca.uhn.fhir.rest.server.servlet.ServletRestfulResponse;
039import ca.uhn.fhir.util.OperationOutcomeUtil;
040import jakarta.servlet.ServletException;
041import jakarta.servlet.http.HttpServletRequest;
042import jakarta.servlet.http.HttpServletResponse;
043import org.apache.commons.collections4.map.HashedMap;
044import org.apache.commons.lang3.exception.ExceptionUtils;
045import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
046
047import java.io.IOException;
048import java.util.Collection;
049import java.util.Collections;
050import java.util.List;
051import java.util.Map;
052import java.util.Map.Entry;
053import java.util.Set;
054
055import static org.apache.commons.lang3.StringUtils.isNotBlank;
056import static org.apache.http.HttpHeaders.CONTENT_ENCODING;
057
058@Interceptor
059public class ExceptionHandlingInterceptor {
060
061        public static final String PROCESSING = Constants.OO_INFOSTATUS_PROCESSING;
062        private static final org.slf4j.Logger ourLog =
063                        org.slf4j.LoggerFactory.getLogger(ExceptionHandlingInterceptor.class);
064        public static final Set<SummaryEnum> SUMMARY_MODE = Collections.singleton(SummaryEnum.FALSE);
065        private Class<?>[] myReturnStackTracesForExceptionTypes;
066
067        @Hook(Pointcut.SERVER_HANDLE_EXCEPTION)
068        public boolean handleException(
069                        RequestDetails theRequestDetails,
070                        BaseServerResponseException theException,
071                        HttpServletRequest theRequest,
072                        HttpServletResponse theResponse)
073                        throws ServletException, IOException {
074                handleException(theRequestDetails, theException);
075                return false;
076        }
077
078        public Object handleException(RequestDetails theRequestDetails, BaseServerResponseException theException)
079                        throws ServletException, IOException {
080                IRestfulResponse response = theRequestDetails.getResponse();
081
082                FhirContext ctx = theRequestDetails.getServer().getFhirContext();
083
084                IBaseOperationOutcome oo = theException.getOperationOutcome();
085                if (oo == null) {
086                        oo = createOperationOutcome(theException, ctx);
087                }
088
089                // Add headers associated with the specific error code
090                if (theException.hasResponseHeaders()) {
091                        Map<String, List<String>> additional = theException.getResponseHeaders();
092                        for (Entry<String, List<String>> next : additional.entrySet()) {
093                                if (isNotBlank(next.getKey()) && next.getValue() != null) {
094                                        String nextKey = next.getKey();
095                                        for (String nextValue : next.getValue()) {
096                                                response.addHeader(nextKey, nextValue);
097                                        }
098                                }
099                        }
100                }
101
102                ResponseDetails responseDetails = BaseResourceReturningMethodBinding.callOutgoingFailureOperationOutcomeHook(
103                                theRequestDetails, oo, theException);
104                try {
105                        resetOutputStreamIfPossible(response);
106                } catch (Throwable t) {
107                        ourLog.error(
108                                        "HAPI-FHIR was unable to reset the output stream during exception handling. The root causes follows:",
109                                        t);
110                }
111
112                return RestfulServerUtils.streamResponseAsResource(
113                                theRequestDetails.getServer(),
114                                responseDetails.getResponseResource(),
115                                SUMMARY_MODE,
116                                responseDetails.getResponseCode(),
117                                false,
118                                false,
119                                theRequestDetails,
120                                null,
121                                null);
122        }
123
124        /**
125         * In some edge cases, the output stream is opened already by the point at which an exception is thrown.
126         * This is a failsafe to that the output stream is writeable in that case. This operation retains status code and headers, but clears the buffer.
127         * Also, it strips the content-encoding header if present, as the method outcome will negotiate its own.
128         */
129        private void resetOutputStreamIfPossible(IRestfulResponse response) {
130                if (response.getClass().isAssignableFrom(ServletRestfulResponse.class)) {
131                        ServletRestfulResponse servletRestfulResponse = (ServletRestfulResponse) response;
132                        HttpServletResponse servletResponse =
133                                        servletRestfulResponse.getRequestDetails().getServletResponse();
134                        Collection<String> headerNames = servletResponse.getHeaderNames();
135                        Map<String, Collection<String>> oldHeaders = new HashedMap<>();
136                        for (String headerName : headerNames) {
137                                oldHeaders.put(headerName, servletResponse.getHeaders(headerName));
138                        }
139
140                        servletResponse.reset();
141                        oldHeaders.entrySet().stream()
142                                        .filter(entry -> !entry.getKey().equals(CONTENT_ENCODING))
143                                        .forEach(entry -> entry.getValue().stream()
144                                                        .forEach(value -> servletResponse.addHeader(entry.getKey(), value)));
145                }
146        }
147
148        @Hook(Pointcut.SERVER_PRE_PROCESS_OUTGOING_EXCEPTION)
149        public BaseServerResponseException preProcessOutgoingException(
150                        RequestDetails theRequestDetails, Throwable theException, HttpServletRequest theServletRequest)
151                        throws ServletException {
152                BaseServerResponseException retVal;
153                if (theException instanceof DataFormatException) {
154                        // Wrapping the DataFormatException as an InvalidRequestException so that it gets sent back to the client as
155                        // a 400 response.
156                        retVal = new InvalidRequestException(theException);
157                } else if (!(theException instanceof BaseServerResponseException)) {
158                        retVal = new InternalErrorException(theException);
159                } else {
160                        retVal = (BaseServerResponseException) theException;
161                }
162
163                if (retVal.getOperationOutcome() == null) {
164                        retVal.setOperationOutcome(createOperationOutcome(
165                                        theException, theRequestDetails.getServer().getFhirContext()));
166                }
167
168                return retVal;
169        }
170
171        private IBaseOperationOutcome createOperationOutcome(Throwable theException, FhirContext ctx)
172                        throws ServletException {
173                IBaseOperationOutcome oo = null;
174                if (theException instanceof BaseServerResponseException) {
175                        oo = ((BaseServerResponseException) theException).getOperationOutcome();
176                }
177
178                /*
179                 * Generate an OperationOutcome to return, unless the exception throw by the resource provider had one
180                 */
181                if (oo == null) {
182                        try {
183                                oo = OperationOutcomeUtil.newInstance(ctx);
184
185                                if (theException instanceof InternalErrorException) {
186                                        ourLog.error("Failure during REST processing", theException);
187                                        populateDetails(ctx, theException, oo);
188                                } else if (theException instanceof BaseServerResponseException) {
189                                        int statusCode = ((BaseServerResponseException) theException).getStatusCode();
190
191                                        // No stack traces for non-server internal errors
192                                        if (statusCode < 500) {
193                                                ourLog.warn("Failure during REST processing: {}", theException.toString());
194                                        } else {
195                                                ourLog.warn("Failure during REST processing", theException);
196                                        }
197
198                                        BaseServerResponseException baseServerResponseException =
199                                                        (BaseServerResponseException) theException;
200                                        populateDetails(ctx, theException, oo);
201                                        if (baseServerResponseException.getAdditionalMessages() != null) {
202                                                for (String next : baseServerResponseException.getAdditionalMessages()) {
203                                                        OperationOutcomeUtil.addIssue(ctx, oo, "error", next, null, PROCESSING);
204                                                }
205                                        }
206                                } else {
207                                        ourLog.error("Failure during REST processing: " + theException.toString(), theException);
208                                        populateDetails(ctx, theException, oo);
209                                }
210                        } catch (Exception e1) {
211                                ourLog.error("Failed to instantiate OperationOutcome resource instance", e1);
212                                throw new ServletException(
213                                                Msg.code(328) + "Failed to instantiate OperationOutcome resource instance", e1);
214                        }
215                } else {
216                        ourLog.error("Unknown error during processing", theException);
217                }
218                return oo;
219        }
220
221        private void populateDetails(FhirContext theCtx, Throwable theException, IBaseOperationOutcome theOo) {
222                if (myReturnStackTracesForExceptionTypes != null) {
223                        for (Class<?> next : myReturnStackTracesForExceptionTypes) {
224                                if (next.isAssignableFrom(theException.getClass())) {
225                                        String detailsValue =
226                                                        theException.getMessage() + "\n\n" + ExceptionUtils.getStackTrace(theException);
227                                        OperationOutcomeUtil.addIssue(theCtx, theOo, "error", detailsValue, null, PROCESSING);
228                                        return;
229                                }
230                        }
231                }
232
233                OperationOutcomeUtil.addIssue(theCtx, theOo, "error", theException.getMessage(), null, PROCESSING);
234        }
235
236        /**
237         * If any server methods throw an exception which extends any of the given exception types, the exception stack trace will be returned to the user. This can be useful for helping to diagnose
238         * issues, but may not be desirable for production situations.
239         *
240         * @param theExceptionTypes
241         *           The exception types for which to return the stack trace to the user.
242         * @return Returns an instance of this interceptor, to allow for easy method chaining.
243         */
244        public ExceptionHandlingInterceptor setReturnStackTracesForExceptionTypes(Class<?>... theExceptionTypes) {
245                myReturnStackTracesForExceptionTypes = theExceptionTypes;
246                return this;
247        }
248}