/**
* 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();
}
}