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