001// Licensed under the Apache License, Version 2.0 (the "License");
002// you may not use this file except in compliance with the License.
003// You may obtain a copy of the License at
004//
005// http://www.apache.org/licenses/LICENSE-2.0
006//
007// Unless required by applicable law or agreed to in writing, software
008// distributed under the License is distributed on an "AS IS" BASIS,
009// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
010// See the License for the specific language governing permissions and
011// limitations under the License.
012
013package org.apache.tapestry5.internal.services;
014
015import org.apache.tapestry5.ContextAwareException;
016import org.apache.tapestry5.ExceptionHandlerAssistant;
017import org.apache.tapestry5.SymbolConstants;
018import org.apache.tapestry5.beanmodel.services.*;
019import org.apache.tapestry5.commons.internal.util.TapestryException;
020import org.apache.tapestry5.commons.util.ExceptionUtils;
021import org.apache.tapestry5.http.Link;
022import org.apache.tapestry5.http.services.Request;
023import org.apache.tapestry5.http.services.Response;
024import org.apache.tapestry5.internal.InternalConstants;
025import org.apache.tapestry5.internal.structure.Page;
026import org.apache.tapestry5.ioc.ServiceResources;
027import org.apache.tapestry5.ioc.annotations.Symbol;
028import org.apache.tapestry5.ioc.internal.OperationException;
029import org.apache.tapestry5.json.JSONObject;
030import org.apache.tapestry5.runtime.ComponentEventException;
031import org.apache.tapestry5.services.ComponentClassResolver;
032import org.apache.tapestry5.services.ExceptionReporter;
033import org.apache.tapestry5.services.RequestExceptionHandler;
034import org.slf4j.Logger;
035
036import javax.servlet.http.HttpServletResponse;
037import java.io.IOException;
038import java.io.OutputStream;
039import java.net.URLEncoder;
040import java.util.Arrays;
041import java.util.HashMap;
042import java.util.List;
043import java.util.Map;
044import java.util.Map.Entry;
045
046/**
047 * Default implementation of {@link RequestExceptionHandler} that displays the standard ExceptionReport page. Similarly to the
048 * servlet spec's standard error handling, the default exception handler allows configuring handlers for specific types of
049 * exceptions. The error-page/exception-type configuration in web.xml does not work in Tapestry application as errors are
050 * wrapped in Tapestry's exception types (see {@link OperationException} and {@link ComponentEventException} ).
051 *
052 * Configurations are flexible. You can either contribute a {@link ExceptionHandlerAssistant} to use arbitrary complex logic
053 * for error handling or a page class to render for the specific exception. Additionally, exceptions can carry context for the
054 * error page. Exception context is formed either from the name of Exception (e.g. SmtpNotRespondingException {@code ->} ServiceFailure mapping
055 * would render a page with URL /servicefailure/smtpnotresponding) or they can implement {@link ContextAwareException} interface.
056 *
057 * If no configured exception type is found, the default exception page {@link SymbolConstants#EXCEPTION_REPORT_PAGE} is rendered.
058 * This fallback exception page must implement the {@link org.apache.tapestry5.services.ExceptionReporter} interface.
059 */
060public class DefaultRequestExceptionHandler implements RequestExceptionHandler
061{
062    private final RequestPageCache pageCache;
063
064    private final PageResponseRenderer renderer;
065
066    private final Logger logger;
067
068    private final String pageName;
069
070    private final Request request;
071
072    private final Response response;
073
074    private final ComponentClassResolver componentClassResolver;
075
076    private final LinkSource linkSource;
077
078    private final ExceptionReporter exceptionReporter;
079    
080    private final boolean productionMode;
081
082    // should be Class<? extends Throwable>, Object but it's not allowed to configure subtypes
083    private final Map<Class, Object> configuration;
084
085    /**
086     * @param configuration
087     *         A map of Exception class and handler values. A handler is either a page class or an ExceptionHandlerAssistant. ExceptionHandlerAssistant can be a class
088     */
089    @SuppressWarnings("rawtypes")
090    public DefaultRequestExceptionHandler(RequestPageCache pageCache,
091                                          PageResponseRenderer renderer,
092                                          Logger logger,
093                                          @Symbol(SymbolConstants.EXCEPTION_REPORT_PAGE)
094                                          String pageName,
095                                          Request request,
096                                          Response response,
097                                          ComponentClassResolver componentClassResolver,
098                                          LinkSource linkSource,
099                                          ServiceResources serviceResources,
100                                          ExceptionReporter exceptionReporter,
101                                          @Symbol(SymbolConstants.PRODUCTION_MODE)
102                                          boolean productionMode,
103                                          Map<Class, Object> configuration)
104    {
105        this.pageCache = pageCache;
106        this.renderer = renderer;
107        this.logger = logger;
108        this.pageName = pageName;
109        this.request = request;
110        this.response = response;
111        this.componentClassResolver = componentClassResolver;
112        this.linkSource = linkSource;
113        this.productionMode = productionMode;
114        this.exceptionReporter = exceptionReporter;
115
116        Map<Class<ExceptionHandlerAssistant>, ExceptionHandlerAssistant> handlerAssistants = new HashMap<Class<ExceptionHandlerAssistant>, ExceptionHandlerAssistant>();
117
118        for (Entry<Class, Object> entry : configuration.entrySet())
119        {
120            if (!Throwable.class.isAssignableFrom(entry.getKey()))
121                throw new IllegalArgumentException(Throwable.class.getName() + " is the only allowable key type but " + entry.getKey().getName()
122                        + " was contributed");
123
124            if (entry.getValue() instanceof Class && ExceptionHandlerAssistant.class.isAssignableFrom((Class) entry.getValue()))
125            {
126                @SuppressWarnings("unchecked")
127                Class<ExceptionHandlerAssistant> handlerType = (Class<ExceptionHandlerAssistant>) entry.getValue();
128                ExceptionHandlerAssistant assistant = handlerAssistants.get(handlerType);
129                if (assistant == null)
130                {
131                    assistant = (ExceptionHandlerAssistant) serviceResources.autobuild(handlerType);
132                    handlerAssistants.put(handlerType, assistant);
133                }
134                entry.setValue(assistant);
135            }
136        }
137        this.configuration = configuration;
138    }
139
140    /**
141     * Handles the exception thrown at some point the request was being processed
142     *
143     * First checks if there was a specific exception handler/page configured for this exception type, it's super class or super-super class.
144     * Renders the default exception page if none was configured.
145     *
146     * @param exception
147     *         The exception that was thrown
148     */
149    @SuppressWarnings({"rawtypes", "unchecked"})
150    public void handleRequestException(Throwable exception) throws IOException
151    {
152        // skip handling of known exceptions if there are none configured
153        if (configuration.isEmpty())
154        {
155            renderException(exception);
156            return;
157        }
158
159        Throwable cause = exception;
160
161        // Depending on where the error was thrown, there could be several levels of wrappers..
162        // For exceptions in component operations, it's OperationException -> ComponentEventException -> <Target>Exception
163
164        // Throw away the wrapped exceptions first
165        while (cause instanceof TapestryException)
166        {
167            if (cause.getCause() == null) break;
168            cause = cause.getCause();
169        }
170
171        Class<?> causeClass = cause.getClass();
172        if (!configuration.containsKey(causeClass))
173        {
174            // try at most two level of superclasses before delegating back to the default exception handler
175            causeClass = causeClass.getSuperclass();
176            if (causeClass == null || !configuration.containsKey(causeClass))
177            {
178                causeClass = causeClass.getSuperclass();
179                if (causeClass == null || !configuration.containsKey(causeClass))
180                {
181                    renderException(exception);
182                    return;
183                }
184            }
185        }
186
187        Object[] exceptionContext = formExceptionContext(cause);
188        Object value = configuration.get(causeClass);
189        Object page = null;
190        ExceptionHandlerAssistant assistant = null;
191        if (value instanceof ExceptionHandlerAssistant)
192        {
193            assistant = (ExceptionHandlerAssistant) value;
194            // in case the assistant changes the context
195            List context = Arrays.asList(exceptionContext);
196            page = assistant.handleRequestException(exception, context);
197            exceptionContext = context.toArray();
198        } else if (!(value instanceof Class))
199        {
200            renderException(exception);
201            return;
202        } else page = value;
203
204        if (page == null) return;
205
206        try
207        {
208            if (page instanceof Class)
209                page = componentClassResolver.resolvePageClassNameToPageName(((Class) page).getName());
210
211            Link link = page instanceof Link
212                    ? (Link) page
213                    : linkSource.createPageRenderLink(page.toString(), false, exceptionContext);
214
215            if (request.isXHR())
216            {
217                OutputStream os = response.getOutputStream("application/json;charset=UTF-8");
218
219                JSONObject reply = new JSONObject();
220                reply.in(InternalConstants.PARTIAL_KEY).put("redirectURL", link.toRedirectURI());
221
222                os.write(reply.toCompactString().getBytes("UTF-8"));
223
224                os.close();
225
226                return;
227            }
228
229            // Normal behavior is just a redirect.
230
231            response.sendRedirect(link);
232        }
233        // The above could throw an exception if we are already on a render request, but it's
234        // user's responsibility not to abuse the mechanism
235        catch (Exception e)
236        {
237            logger.warn("A new exception was thrown while trying to handle an instance of {}.",
238                    exception.getClass().getName(), e);
239            // Nothing to do but delegate
240            renderException(exception);
241        }
242    }
243
244    private void renderException(Throwable exception) throws IOException
245    {
246        int statusCode = HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
247        // Some special handling for REST endpoint not found exceptions
248        if (exception instanceof RestEndpointNotFoundException ||
249                exception.getCause() instanceof RestEndpointNotFoundException)
250        {
251            if (productionMode)
252            {
253                response.sendError(HttpServletResponse.SC_NOT_FOUND, "REST endpoint not found or endpoint found but no response provided");
254                return;
255            }
256            statusCode = HttpServletResponse.SC_NOT_FOUND;
257        }
258        
259        logger.error("Processing of request failed with uncaught exception: {}", exception, exception);
260
261        // In the case where one of the contributed rules, above, changes the behavior, then we don't report the
262        // exception. This is just for exceptions that are going to be rendered, real failures.
263        exceptionReporter.reportException(exception);
264
265        // TAP5-233: Make sure the client knows that an error occurred.
266
267        response.setStatus(statusCode);
268
269        // TAP5-2768: Don't leak Exception details to client in production mode
270        String headerValue = null;
271        if (productionMode)
272        {
273            headerValue = "An error occurred.";
274        } else
275        {
276            String rawMessage = ExceptionUtils.toMessage(exception);
277
278            // Encode it compatibly with the JavaScript escape() function.
279
280            headerValue = URLEncoder.encode(rawMessage, "UTF-8").replace("+", "%20");
281        }
282
283        response.setHeader("X-Tapestry-ErrorMessage", headerValue);
284
285        Page page = pageCache.get(pageName);
286
287        org.apache.tapestry5.services.ExceptionReporter rootComponent = (org.apache.tapestry5.services.ExceptionReporter) page.getRootComponent();
288
289        // Let the page set up for the new exception.
290
291        rootComponent.reportException(exception);
292
293        renderer.renderPageResponse(page);
294    }
295
296    /**
297     * Form exception context either from the name of the exception, or the context the exception contains if it's of type
298     * {@link ContextAwareException}
299     *
300     * @param exception
301     *         The exception that the context is formed for
302     * @return Returns an array of objects to be used as the exception context
303     */
304    @SuppressWarnings({"unchecked", "rawtypes"})
305    protected Object[] formExceptionContext(Throwable exception)
306    {
307        if (exception instanceof ContextAwareException) return ((ContextAwareException) exception).getContext();
308
309        Class exceptionClass = exception.getClass();
310        // pick the first class in the hierarchy that's not anonymous, probably no reason check for array types
311        while ("".equals(exceptionClass.getSimpleName()))
312            exceptionClass = exceptionClass.getSuperclass();
313
314        // check if exception type is plain runtimeException - yes, we really want the test to be this way
315        if (exceptionClass.isAssignableFrom(RuntimeException.class))
316            return exception.getMessage() == null ? new Object[0] : new Object[]{exception.getMessage().toLowerCase()};
317
318        // otherwise, form the context from the exception type name
319        String exceptionType = exceptionClass.getSimpleName();
320        if (exceptionType.endsWith("Exception")) exceptionType = exceptionType.substring(0, exceptionType.length() - 9);
321        return new Object[]{exceptionType.toLowerCase()};
322    }
323
324}