/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you 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. */ package org.jooby.whoops; import static javaslang.API.$; import static javaslang.API.Case; import static javaslang.API.Match; import static javaslang.Predicates.instanceOf; import java.io.File; import java.io.InputStream; import java.io.StringWriter; import java.io.Writer; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.IntStream; import org.jooby.Env; import org.jooby.Err; import org.jooby.Err.Handler; import org.jooby.Jooby; import org.jooby.MediaType; import org.jooby.Route; import org.jooby.Router; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.base.Throwables; import com.google.common.cache.CacheBuilder; import com.google.common.collect.ImmutableMap; import com.google.common.io.ByteStreams; import com.google.common.io.Closeables; import com.google.inject.Binder; import com.mitchellbosecke.pebble.PebbleEngine; import com.mitchellbosecke.pebble.loader.ClasspathLoader; import com.mitchellbosecke.pebble.template.PebbleTemplate; import com.typesafe.config.Config; import javaslang.Lazy; import javaslang.control.Try; /** * <h1>whoops</h1> * <p> * Pretty error page that helps you debug your web application. * </p> * <p> * <strong>NOTE</strong>: This module is base on <a href="https://github.com/filp/whoops">whoops</a> * and uses the same front end resources. * </p> * * <h2>exports</h2> * <ul> * <li>A {@link Err.Handler pretty error page}</li> * </ul> * * <h2>usage</h2> * * <pre>{@code * { * use(new Whoops()); * * get("/", req -> { * throw new IllegalStateException("Something broken!"); * }); * } * }</pre> * * <p> * The pretty error page handler is available in development mode: * <code>application.env = dev</code> * </p> * * <h2>custom err pages</h2> * <p> * The pretty error page is implemented via {@link Router#err(Handler)}. You might run into troubles * if your application require custom error pages. On those cases you probably won't use this module * or if apply one of the following options: * </p> * * <h3>whoops as last err handler</h3> * <p> * This option is useful if you have custom error pages on some specific exceptions: * </p> * <pre>{@code * { * err(NotFoundException.class, (req, rsp, err) -> { * // custom not found * }); * * err(AccessDeniedException.class, (req, rsp, err) -> { * // custom access denied * }); * * // not handled it use whoops * use(new Whoops()); * } * }</pre> * <p> * Here the custom error page for <code>NotFoundException</code> or * <code>AccessDeniedException</code> will be render before the <code>Whoops</code> error handler. * </p> * * <h3>whoops on dev</h3> * <p> * This options will active <code>Whoops</code> in <code>dev</code> and the custom err pages in * <code>prod-like</code> environments: * </p> * * <pre>{@code * { * on("dev", () -> { * use(new Whoops()); * }) * .orElse(() -> { * err((req, rsp, err) -> { * // custom not found * }); * }); * } * }</pre> * * @author edgar * @since 1.0.0.CR4 */ public class Whoops implements Jooby.Module { private static final String HANDLER = "org.jooby.internal.HttpHandlerImpl"; private static int SAMPLE_SIZE = 10; private static BiFunction<Path, Integer, String> openWith = (p, l) -> ""; /** The logging system. */ private final Logger log = LoggerFactory.getLogger(getClass()); private final int maxFrameSize; /** * Creates a new {@link Whoops} module. * * @param maxFrameSize Max number of frame to show in the pretty error page. */ public Whoops(final int maxFrameSize) { this.maxFrameSize = maxFrameSize; } /** * Creates a new {@link Whoops} module with max frame size of 8. */ public Whoops() { this(8); } @Override public void configure(final Env env, final Config conf, final Binder binder) { boolean whoops = conf.hasPath("whoops.enabled") ? conf.getBoolean("whoops.enabled") : "dev".equals(env.name()); if (whoops) { ClassLoader loader = env.router().getClass().getClassLoader(); Handler handler = prettyPage(loader, SourceLocator.local(), maxFrameSize, log); env.router().err(tryPage(handler, log)); } } static Handler tryPage(final Handler handler, final Logger log) { return (req, rsp, ex) -> Try.run(() -> handler.handle(req, rsp, ex)).onFailure(cause -> { log.debug("execution of pretty err page resulted in exception", cause); }); } private static Handler prettyPage(final ClassLoader loader, final SourceLocator locator, final int maxStackSize, final Logger log) { String css = readString(loader, "css/whoops.base.css"); String clipboard = readString(loader, "js/clipboard.min.js"); String js = readString(loader, "js/whoops.base.js"); String zepto = readString(loader, "js/zepto.min.js"); ClasspathLoader cpathloader = new ClasspathLoader(); cpathloader.setPrefix("whoops"); cpathloader.setSuffix(".html"); PebbleEngine engine = new PebbleEngine.Builder() .loader(cpathloader) .templateCache(CacheBuilder.newBuilder().maximumSize(10).build()) .build(); /** Lazy compile template and keep it */ Lazy<PebbleTemplate> template = Lazy.of(() -> Try.of(() -> engine.getTemplate("layout")).get()); return (req, rsp, err) -> { // only html, ignore any other request and fallback to default handler if (req.accepts(MediaType.html).isPresent()) { // is this a real Err? or we just wrap it? Throwable cause = Optional.ofNullable(err.getCause()).orElse(err); // dump causes as a list List<Throwable> causal = Throwables.getCausalChain(cause); // get the cause (initial exception) Throwable head = causal.get(causal.size() - 1); String message = message(head); Map<String, Object> envdata = new LinkedHashMap<>(); envdata.put("response", dump(() -> ImmutableMap.of("status", rsp.status().get()))); /** route */ envdata.put("route", dump(() -> { Route route = req.route(); ImmutableMap.Builder<String, Object> map = ImmutableMap.builder(); return map .put("method", route.method()) .put("path", route.path()) .put("path vars", route.vars()) .put("pattern", route.pattern()) .put("name", route.name()) .put("attributes", route.attributes()).build(); })); /** request params */ envdata.put("request params", dump(() -> req.params().toMap())); /** request locals */ envdata.put("request locals", dump(req::attributes)); /** http headers */ envdata.put("request headers", dump(req::headers)); /** session */ req.ifSession().ifPresent(s -> envdata.put("session", dump(s::attributes))); List<Map<String, Object>> frames = causal.stream().filter(it -> it != head) .map(it -> frame(loader, locator, it, it.getStackTrace()[0])) .collect(Collectors.toList()); frames.addAll(frames(loader, locator, head)); // truncate frames frames = frames.subList(0, Math.min(maxStackSize, frames.size())); Map<String, Object> context = ImmutableMap.<String, Object> builder() .put("stylesheet", css) .put("zepto", zepto) .put("clipboard", clipboard) .put("javascript", js) .put("chain", causal) .put("q", head.getClass().getName() + ": " + message) .put("message", message) .put("stacktrace", Throwables.getStackTraceAsString(cause)) .put("frames", frames) .put("env", envdata) .build(); Writer writer = new StringWriter(); template.get().evaluate(writer, context); log.error("execution of: {}{} resulted in exception\nRoute:\n{}\n\nStacktrace:", req.method(), req.path(), req.route().print(6), err); rsp.type(MediaType.html).send(writer.toString()); } }; } private static String message(final Throwable cause) { return Match(cause).of( Case(instanceOf(Supplier.class), s -> s.get().toString()), Case($(), () -> Optional.ofNullable(cause.getMessage()).orElse(""))); } private static <T> Map<String, String> dump(final Supplier<Map<String, T>> hash) { return dump(hash, v -> v.toString()); } private static <T> Map<String, String> dump(final Supplier<Map<String, T>> hash, final Function<T, String> map) { Map<String, String> data = new LinkedHashMap<>(); hash.get().forEach((n, v) -> data.put(n, map.apply(v))); return data; } static String readString(final ClassLoader loader, final String path) { return Try.of(() -> { InputStream stream = null; try { stream = loader.getResourceAsStream("whoops/" + path); return new String(ByteStreams.toByteArray(stream), StandardCharsets.UTF_8); } finally { Closeables.closeQuietly(stream); } }).get(); } static List<Map<String, Object>> frames(final ClassLoader loader, final SourceLocator locator, final Throwable cause) { List<StackTraceElement> stacktrace = Arrays.asList(cause.getStackTrace()); int limit = IntStream.range(0, stacktrace.size()) .filter(i -> stacktrace.get(i).getClassName().equals(HANDLER)).findFirst() .orElse(stacktrace.size()); return stacktrace.stream() // trunk stack at HttpHandlerImpl (skip servers stack) .limit(limit) .map(e -> frame(loader, locator, cause, e)) .collect(Collectors.toList()); } @SuppressWarnings("rawtypes") static Map<String, Object> frame(final ClassLoader loader, final SourceLocator locator, final Throwable cause, final StackTraceElement e) { int line = Math.max(e.getLineNumber(), 1); String className = e.getClassName(); SourceLocator.Source source = locator.source(className); int[] range = source.range(line, SAMPLE_SIZE); int lineStart = range[0]; int lineNth = line - lineStart; Path filePath = source.getPath(); Optional<Class> clazz = findClass(loader, className); String filename = Optional.ofNullable(e.getFileName()).orElse("~unknown"); return ImmutableMap.<String, Object> builder() .put("fileName", new File(filename).getName()) .put("methodName", Optional.ofNullable(e.getMethodName()).orElse("~unknown")) .put("lineNumber", line) .put("lineStart", lineStart + 1) .put("lineNth", lineNth) .put("location", Optional.ofNullable(clazz.map(Whoops::locationOf) .orElse(new File(filename).getParent())).orElse(filename)) .put("source", source.source(range[0], range[1])) .put("open", openWith.apply(filePath, line)) .put("type", clazz.map(c -> c.getSimpleName()).orElse(new File(filename).getName())) .put("comments", Arrays.asList( ImmutableMap.of( "context", cause.getClass().getName(), "text", message(cause)))) .build(); } @SuppressWarnings("rawtypes") static String locationOf(final Class clazz) { return Optional.ofNullable(clazz.getResource(clazz.getSimpleName() + ".class")) .map(url -> Try.of(() -> { String path = url.getPath(); int i = path.indexOf("!"); if (i > 0) { // jar url String jar = path.substring(0, i); return jar.substring(Math.max(jar.lastIndexOf('/'), -1) + 1); } String cfile = clazz.getName().replace(".", "/") + ".class"; String relativePath = path.replace(cfile, ""); return new File(System.getProperty("user.dir")) .toPath() .relativize(Paths.get(relativePath).toFile().getCanonicalFile().toPath()) .toString(); }).getOrElse("~unknown")) .orElse("~unknown"); } @SuppressWarnings("rawtypes") static Optional<Class> findClass(final ClassLoader loader, final String name) { return Arrays .asList(loader, Thread.currentThread().getContextClassLoader()) .stream() // we don't care about exception .map(cl -> Try.<Class> of(() -> cl.loadClass(name)).getOrElse((Class) null)) .filter(Objects::nonNull) .findFirst(); } }