Skip to content

Commit

Permalink
feat: add simple Error page (#34)
Browse files Browse the repository at this point in the history
The error page is displayed when a handler returns a Result::Err.

A nice-to-have thing would be to catch panics as well.
  • Loading branch information
m4tx authored Sep 25, 2024
1 parent 8b128f0 commit 3ab0e57
Show file tree
Hide file tree
Showing 6 changed files with 357 additions and 6 deletions.
135 changes: 135 additions & 0 deletions flareon/src/error_page.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
use std::sync::Arc;

use askama::Template;
use log::error;

use crate::router::Router;
use crate::{Error, Result, StatusCode};

#[derive(Debug)]
pub(super) struct FlareonDiagnostics {
router: Arc<Router>,
}

impl FlareonDiagnostics {
#[must_use]
pub(super) fn new(router: Arc<Router>) -> Self {
Self { router }
}
}

#[derive(Debug, Template)]
#[template(path = "error.html")]
struct ErrorPageTemplate {
error_data: Vec<ErrorData>,
route_data: Vec<RouteData>,
}

#[derive(Debug)]
struct ErrorData {
description: String,
debug_str: String,
is_flareon_error: bool,
}

#[derive(Debug)]
struct RouteData {
index: String,
path: String,
kind: String,
name: String,
}

#[must_use]
pub(super) fn handle_response_error(
error: Error,
diagnostics: FlareonDiagnostics,
) -> axum::response::Response {
let response = build_error_response(error, diagnostics);

match response {
Ok(error_str) => axum::response::Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(axum::body::Body::new(error_str))
.unwrap_or_else(|_| build_flareon_failure_page()),
Err(error) => {
error!("Failed to render error page: {}", error);
build_flareon_failure_page()
}
}
}

fn build_error_response(error: Error, diagnostics: FlareonDiagnostics) -> Result<String> {
let mut error_data = Vec::new();
build_error_data(&mut error_data, &error);
let mut route_data = Vec::new();
build_route_data(&mut route_data, &diagnostics.router, "", "");

let template = ErrorPageTemplate {
error_data,
route_data,
};
Ok(template.render()?)
}

fn build_route_data(
route_data: &mut Vec<RouteData>,
router: &Router,
url_prefix: &str,
index_prefix: &str,
) {
for (index, route) in router.routes().iter().enumerate() {
route_data.push(RouteData {
index: format!("{index_prefix}{index}"),
path: route.url(),
kind: match route.kind() {
crate::router::RouteKind::Router => if route_data.is_empty() {
"RootRouter"
} else {
"Router"
}
.to_owned(),
crate::router::RouteKind::Handler => "View".to_owned(),
},
name: route.name().unwrap_or_default().to_owned(),
});

if let Some(inner_router) = route.router() {
build_route_data(
route_data,
inner_router,
&format!("{}{}", url_prefix, route.url()),
&format!("{index_prefix}{index}."),
);
}
}
}

fn build_error_data(vec: &mut Vec<ErrorData>, error: &(dyn std::error::Error + 'static)) {
let data = ErrorData {
description: error.to_string(),
debug_str: format!("{error:#?}"),
is_flareon_error: error.is::<Error>(),
};
vec.push(data);

if let Some(source) = error.source() {
build_error_data(vec, source);
}
}

const FAILURE_PAGE: &[u8] = include_bytes!("../templates/fail.html");

/// A last-resort error page.
///
/// This page is displayed when an error occurs that prevents Flareon from
/// rendering a proper error page. This page is very simple and should only be
/// displayed in the event of a catastrophic failure, likely caused by a bug in
/// Flareon itself.
#[must_use]
fn build_flareon_failure_page() -> axum::response::Response {
axum::response::Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(axum::body::Body::from(FAILURE_PAGE))
.expect("Building the Flareon failure page should not fail")
}
12 changes: 6 additions & 6 deletions flareon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod headers;
#[doc(hidden)]
#[path = "private.rs"]
pub mod __private;
mod error_page;
pub mod middleware;
pub mod request;
pub mod response;
Expand All @@ -46,6 +47,7 @@ use request::Request;
use router::{Route, Router};
use tower::Service;

use crate::error_page::FlareonDiagnostics;
use crate::response::Response;

/// A type alias for a result that can return a `flareon::Error`.
Expand Down Expand Up @@ -450,7 +452,10 @@ where

pass_to_axum(request, &mut handler)
.await
.unwrap_or_else(handle_response_error)
.unwrap_or_else(|error| {
let diagnostics = FlareonDiagnostics::new(router.clone());
error_page::handle_response_error(error, diagnostics)
})
};

eprintln!(
Expand Down Expand Up @@ -514,11 +519,6 @@ impl Html {
}
}

#[allow(clippy::needless_pass_by_value)]
fn handle_response_error(_error: Error) -> axum::response::Response {
todo!("500 error handler is not implemented yet")
}

#[cfg(test)]
mod tests {
use std::pin::Pin;
Expand Down
32 changes: 32 additions & 0 deletions flareon/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,32 @@ impl Route {
name: None,
}
}

#[must_use]
pub fn url(&self) -> String {
self.url.to_string()
}

#[must_use]
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}

#[must_use]
pub(crate) fn kind(&self) -> RouteKind {
match &self.view {
RouteInner::Handler(_) => RouteKind::Handler,
RouteInner::Router(_) => RouteKind::Router,
}
}

#[must_use]
pub(crate) fn router(&self) -> Option<&Router> {
match &self.view {
RouteInner::Router(router) => Some(router),
_ => None,
}
}
}

fn handle_not_found() -> Response {
Expand All @@ -207,6 +233,12 @@ fn handle_not_found() -> Response {
)
}

#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub(crate) enum RouteKind {
Handler,
Router,
}

#[derive(Clone)]
enum RouteInner {
Handler(Arc<Box<dyn RequestHandler + Send + Sync>>),
Expand Down
101 changes: 101 additions & 0 deletions flareon/templates/error.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
*, *::before, *::after {
box-sizing: border-box;
}

* {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
font-family: "Open Sans", sans-serif;
vertical-align: baseline;
}

body {
line-height: 1.5;
}

img, picture, video, canvas, svg {
display: block;
max-width: 100%;
}

input, button, textarea, select {
font: inherit;
}

p, h1, h2, h3, h4, h5, h6 {
overflow-wrap: break-word;
}

em {
font-style: italic;
}

body {
background-color: #e8edf1;
padding: 1rem;
}

h1 {
margin: -1rem -1rem 1rem -1rem;
padding: 0.5rem;
font-size: 2rem;
font-weight: lighter;

background-color: rgb(255, 63, 79);
color: white;
}

h2 {
font-size: 1.5rem;
margin-top: .75rem;
}

h3 {
font-size: 1.25rem;
margin-top: .5rem;
}

table, th, td {
border: 1px solid #d9dde1;
border-collapse: collapse;
}

table > thead {
vertical-align: bottom;
}

table th {
font-weight: bold;
}

th, td {
padding: .5rem;
border-bottom-color: #d9dde1;
border-bottom-width: 1px;
}

pre {
white-space: pre-wrap;
word-wrap: break-word;

font-size: .75rem;
font-family: "Noto Sans Mono", monospace;
}

.badge {
color: #fff;
background-color: #1876ff;

display: inline-block;
font-size: 0.7em;
padding: 0.25em 0.5em;
border-radius: 1em;
line-height: 1;

text-align: center;
white-space: nowrap;
vertical-align: baseline;
}
66 changes: 66 additions & 0 deletions flareon/templates/error.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flareon failure</title>

<style>
{% include "error.css" %}
</style>
</head>
<body>
<h1>Flareon failure</h1>
<p>An error occurred when handling a request.</p>

<h2>Errors</h2>

<table>
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Description</th>
<th scope="col">Structure</th>
</tr>
</thead>
<tbody>
{% for error in error_data %}
<tr>
<th scope="row">{{ loop.index0 }}</th>
<td>{{ error.description }}</td>
<td>
{% if error.is_flareon_error %}<code class="badge">flareon::Error</code>{% endif %}
<pre>{{ error.debug_str }}</pre>
</td>
</tr>
{% endfor %}
</tbody>
</table>

<h2>Diagnostics</h2>

<h3>Routes</h3>

<table>
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">URL</th>
<th scope="col">Type</th>
<th scope="col">Name</th>
</tr>
</thead>
<tbody>
{% for route in route_data %}
<tr>
<th scope="row">{{ route.index }}</th>
<td>{% if route.path.is_empty() %}<em>&lt;empty&gt;</em>{% else %}{{ route.path }}{% endif %}</td>
<td>{{ route.kind }}</td>
<td>{% if route.name.is_empty() %}<em>&lt;none&gt;</em>{% else %}{{ route.name }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>

</body>
</html>
Loading

0 comments on commit 3ab0e57

Please sign in to comment.