From 3ab0e57e29f476590570221a0a6c793d5c1636b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Wed, 25 Sep 2024 14:06:44 +0100 Subject: [PATCH] feat: add simple Error page (#34) The error page is displayed when a handler returns a Result::Err. A nice-to-have thing would be to catch panics as well. --- flareon/src/error_page.rs | 135 +++++++++++++++++++++++++++++++++++ flareon/src/lib.rs | 12 ++-- flareon/src/router.rs | 32 +++++++++ flareon/templates/error.css | 101 ++++++++++++++++++++++++++ flareon/templates/error.html | 66 +++++++++++++++++ flareon/templates/fail.html | 17 +++++ 6 files changed, 357 insertions(+), 6 deletions(-) create mode 100644 flareon/src/error_page.rs create mode 100644 flareon/templates/error.css create mode 100644 flareon/templates/error.html create mode 100644 flareon/templates/fail.html diff --git a/flareon/src/error_page.rs b/flareon/src/error_page.rs new file mode 100644 index 0000000..5dc272d --- /dev/null +++ b/flareon/src/error_page.rs @@ -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, +} + +impl FlareonDiagnostics { + #[must_use] + pub(super) fn new(router: Arc) -> Self { + Self { router } + } +} + +#[derive(Debug, Template)] +#[template(path = "error.html")] +struct ErrorPageTemplate { + error_data: Vec, + route_data: Vec, +} + +#[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 { + 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, + 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, error: &(dyn std::error::Error + 'static)) { + let data = ErrorData { + description: error.to_string(), + debug_str: format!("{error:#?}"), + is_flareon_error: error.is::(), + }; + 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") +} diff --git a/flareon/src/lib.rs b/flareon/src/lib.rs index 79f3a82..4a3ce13 100644 --- a/flareon/src/lib.rs +++ b/flareon/src/lib.rs @@ -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; @@ -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`. @@ -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!( @@ -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; diff --git a/flareon/src/router.rs b/flareon/src/router.rs index ade7295..52a4e32 100644 --- a/flareon/src/router.rs +++ b/flareon/src/router.rs @@ -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 { @@ -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>), diff --git a/flareon/templates/error.css b/flareon/templates/error.css new file mode 100644 index 0000000..789971d --- /dev/null +++ b/flareon/templates/error.css @@ -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; +} diff --git a/flareon/templates/error.html b/flareon/templates/error.html new file mode 100644 index 0000000..1f7fb76 --- /dev/null +++ b/flareon/templates/error.html @@ -0,0 +1,66 @@ + + + + + + Flareon failure + + + + +

Flareon failure

+

An error occurred when handling a request.

+ +

Errors

+ + + + + + + + + + + {% for error in error_data %} + + + + + + {% endfor %} + +
#DescriptionStructure
{{ loop.index0 }}{{ error.description }} + {% if error.is_flareon_error %}flareon::Error{% endif %} +
{{ error.debug_str }}
+
+ +

Diagnostics

+ +

Routes

+ + + + + + + + + + + + {% for route in route_data %} + + + + + + + {% endfor %} + +
#URLTypeName
{{ route.index }}{% if route.path.is_empty() %}<empty>{% else %}{{ route.path }}{% endif %}{{ route.kind }}{% if route.name.is_empty() %}<none>{% else %}{{ route.name }}{% endif %}
+ + + diff --git a/flareon/templates/fail.html b/flareon/templates/fail.html new file mode 100644 index 0000000..b86155e --- /dev/null +++ b/flareon/templates/fail.html @@ -0,0 +1,17 @@ + + + + + + Flareon failure + + +

Flareon failure

+

An error occurred when trying to build Flareon error page.

+

+ If you are a user, please report this to the website administrator. If you are a website administrator, this is + likely a Flareon bug. Please report it on the Flareon bug + tracker. +

+ +