/* * #%L * Wisdom-Framework * %% * Copyright (C) 2013 - 2014 Wisdom Framework * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * #L% */ package org.wisdom.error; import com.fasterxml.jackson.databind.node.ObjectNode; import org.apache.commons.io.FileUtils; import org.apache.felix.ipojo.annotations.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.wisdom.api.DefaultController; import org.wisdom.api.bodies.NoHttpBody; import org.wisdom.api.configuration.ApplicationConfiguration; import org.wisdom.api.content.Json; import org.wisdom.api.exceptions.ExceptionMapper; import org.wisdom.api.exceptions.HttpException; import org.wisdom.api.http.Context; import org.wisdom.api.http.*; import org.wisdom.api.interception.Filter; import org.wisdom.api.interception.RequestContext; import org.wisdom.api.router.Route; import org.wisdom.api.router.Router; import org.wisdom.api.templates.Template; import java.io.File; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.util.List; import java.util.regex.Pattern; /** * Wisdom default error handler. * This component exposes a {@link org.wisdom.api.interception.Filter} handling unbound routes and internal errors. */ @Component @Provides(specifications = Filter.class) @Instantiate public class DefaultPageErrorHandler extends DefaultController implements Filter { /** * The logger used by the filter. */ private static final Logger LOGGER = LoggerFactory.getLogger("wisdom-error"); /** * The pattern to interceptor all requests. */ public static final Pattern ALL_REQUESTS = Pattern.compile("/.*"); /** * Empty Content. */ public static final String EMPTY_CONTENT = ""; /** * The 404 template. */ @Requires(filter = "(name=error/404)", proxy = false, optional = true, id = "404") private Template noroute; /** * The 500 template. */ @Requires(filter = "(name=error/500)", proxy = false, optional = true, id = "500") private Template internalerror; /** * The 500 template. */ @Requires(filter = "(name=error/pipeline)", proxy = false, optional = true, id = "pipeline") protected Template pipeline; /** * The router. */ @Requires protected Router router; /** * The application configuration. */ @Requires protected ApplicationConfiguration configuration; /** * The JSON service. */ @Requires protected Json json; /** * The exception mappers. */ @Requires(optional = true) protected ExceptionMapper[] mappers; /** * The directory where error report (created by watchers) are created. */ private File pipelineErrorDirectory; /** * Methods called when this component is starting. It builds the pipeline error directory from the * configuration's base directory. */ @Validate public void start() { pipelineErrorDirectory = new File(configuration.getBaseDir().getParentFile(), "pipeline"); } /** * @return the first error file contained in the pipeline error's directory, {@literal null} if none. Notice that * the returned file may depend on the operating system. */ public File getFirstErrorFile() { if (!pipelineErrorDirectory.isDirectory()) { return null; } // We make the assumption that the directory only store error report and nothing else. File[] files = pipelineErrorDirectory.listFiles(); if (files == null || files.length == 0) { return null; } // Return the first error report. return files[0]; } /** * Generates the error page. * * @param context the context. * @param route the route * @param e the thrown error * @return the HTTP result serving the error page */ private Result renderInternalError(Context context, Route route, Throwable e) { Throwable localException; // If the template is not there, just wrap the exception within a JSON Object. if (internalerror == null) { return internalServerError(e); } // Manage ITE if (e instanceof InvocationTargetException) { localException = ((InvocationTargetException) e).getTargetException(); } else { localException = e; } // Retrieve the cause if any. String cause; StackTraceElement[] stack; if (localException.getCause() != null) { cause = localException.getCause().getMessage(); stack = localException.getCause().getStackTrace(); } else { cause = localException.getMessage(); stack = localException.getStackTrace(); } // Retrieve the file name. String fileName = null; int line = -1; if (stack != null && stack.length != 0) { fileName = stack[0].getFileName(); line = stack[0].getLineNumber(); } // Remove iPOJO trace from the stack trace. List<StackTraceElement> cleaned = StackTraceUtils.cleanup(stack); // We are good to go ! return internalServerError(render(internalerror, "route", route, "context", context, "exception", localException, "message", localException.getMessage(), "cause", cause, "file", fileName, "line", line, "stack", cleaned)); } /** * The interception method. When the request is unbound, generate a 404 page. When the controller throws an * exception generates a 500 page. * * @param route the route * @param context the filter context * @return the generated result. * @throws Exception if anything bad happen */ @Override public Result call(Route route, RequestContext context) throws Exception { // Manage the error file. // In dev mode, if the watching pipeline throws an error, this error is stored in the error.json file // If this file exist, we should display a page telling the user that something terrible happened in his last // change. if (configuration.isDev() && context.request().accepts(MimeTypes.HTML) && pipeline != null) { // Check whether the error file is there File error = getFirstErrorFile(); if (error != null) { logger().debug("Error file detected, preparing rendering"); try { return renderPipelineError(error); } catch (IOException e) { LOGGER.error("An exception occurred while generating the error page for {} {}", route.getHttpMethod(), route.getUrl(), e); return renderInternalError(context.context(), route, e); } } } try { Result result = context.proceed(); if (result.getStatusCode() == NOT_FOUND && result.getRenderable() instanceof NoHttpBody) { // HEAD Implementation. if (route.getHttpMethod() == HttpMethod.HEAD) { return switchToGet(route, context); } return renderNotFound(route, result); } return result; } catch (InvocationTargetException e) { Throwable cause = e.getCause(); LOGGER.error("An exception occurred while processing request {} {}", route.getHttpMethod(), route.getUrl(), cause); // if it is and the cause is a HTTP Exception, return that one if (cause instanceof HttpException) { // If we catch a HTTP Exception, just return the built result. LOGGER.error("A HTTP exception occurred while processing request {} {}", route.getHttpMethod(), route.getUrl(), e); return ((HttpException) cause).toResult(); } // if we have a mapper for that exception, use it. for (ExceptionMapper mapper : mappers) { if (mapper.getExceptionClass().equals(cause.getClass())) { //We can safely cast here, as we have the previous class check; //noinspection unchecked return mapper.toResult((Exception) cause); } } return renderInternalError(context.context(), route, e); } catch (Exception e) { LOGGER.error("An exception occurred while processing request {} {}", route.getHttpMethod(), route.getUrl(), e); Throwable cause = e.getCause(); // if we have a mapper for that exception, use it. for (ExceptionMapper mapper : mappers) { if (mapper.getExceptionClass().equals(cause.getClass())) { //We can safely cast here, as we have the previous class check; //noinspection unchecked return mapper.toResult((Exception) cause); } } // Used when it's not an invocation target exception, or when it is one but we don't have custom action // to handle it. return renderInternalError(context.context(), route, e); } } private Result renderPipelineError(File error) throws IOException { String content = FileUtils.readFileToString(error); ObjectNode node = (ObjectNode) json.parse(content); String message = node.get("message").asText(); String file = null; if (node.get("file") != null) { file = node.get("file").asText(); } String watcher = node.get("watcher").asText(); int line = -1; int character = -1; if (node.get("line") != null) { line = node.get("line").asInt(); } String title = null; if (node.get("title") != null) { title = node.get("title").asText(); } if (node.get("character") != null) { character = node.get("character").asInt(); } String fileContent = ""; InterestingLines lines = null; File source = null; if (file != null) { source = new File(file); if (source.isFile()) { fileContent = FileUtils.readFileToString(source); if (line != -1 && line != 0) { lines = InterestingLines.extractInterestedLines(fileContent, line, 4, logger()); } } } return internalServerError(render(pipeline, "title", title, "message", message, "source", source, "line", line, "character", character, "lines", lines, "watcher", watcher)); } private Result renderNotFound(Route route, Result result) { if (noroute == null) { return result; } else { return Results.notFound(render(noroute, "method", route.getHttpMethod(), "uri", route.getUrl(), "routes", router.getRoutes() )); } } private Result switchToGet(Route route, RequestContext context) { // A HEAD request was emitted, and unfortunately, no action handled it. Switch to GET. Route getRoute = router.getRouteFor(HttpMethod.GET, route.getUrl()); if (getRoute == null || getRoute.isUnbound()) { return renderNotFound(route, Results.notFound()); } else { try { Result result = getRoute.invoke(); // Replace the content with EMPTY_CONTENT but we need to preserve the headers (CONTENT-TYPE and // CONTENT-LENGTH). These headers may not have been set, so we searches values in the renderable // objects too. final Renderable renderable = result.getRenderable(); final String type = result.getHeaders().get(HeaderNames.CONTENT_TYPE); final String length = result.getHeaders().get(HeaderNames.CONTENT_LENGTH); Result newResult = result.render(EMPTY_CONTENT); if (type != null) { newResult.with(HeaderNames.CONTENT_TYPE, type); } else if (renderable != null) { newResult.with(HeaderNames.CONTENT_TYPE, renderable.mimetype()); } if (length != null) { newResult.with(HeaderNames.CONTENT_LENGTH, length); } else if (renderable != null) { logger().info("Length from renderable : " + renderable.length()); newResult.with(HeaderNames.CONTENT_LENGTH, String.valueOf(renderable.length())); } return newResult; } catch (Exception exception) { LOGGER.error("An exception occurred while processing request {} {}", route.getHttpMethod(), route.getUrl(), exception); return renderInternalError(context.context(), route, exception); } } } /** * Gets the Regex Pattern used to determine whether the route is handled by the filter or not. * Notice that the router are caching these patterns and so cannot changed. */ @Override public Pattern uri() { return ALL_REQUESTS; } /** * Gets the filter priority, determining the position of the filter in the filter chain. Filter with a high * priority are called first. Notice that the router are caching these priorities and so cannot changed. * <p> * It is heavily recommended to allow configuring the priority from the Application Configuration. * * @return the priority */ @Override public int priority() { return 1000; } }