Skip to main content

ocre/
view.rs

1//! HTML rendering (feature `html`): askama templates and HTML error pages.
2
3use askama::Template;
4use axum::{
5    body::Body,
6    http::{HeaderValue, StatusCode, header},
7    response::{Html, IntoResponse, Response},
8};
9
10use crate::{Error, FieldError, Result};
11
12/// Renders an askama template (compiled at build time) into an HTML response.
13///
14/// askama escapes interpolated values; never mark user input `|safe`.
15/// Rendering is plain Rust string building: no binding call, a little CPU.
16/// Requires the `html` feature.
17///
18/// # Errors
19///
20/// [`Error::Internal`] (500, logged) when the template fails at run time,
21/// e.g. a `Display` implementation or a filter returns an error.
22///
23/// # Examples
24///
25/// ```
26/// use askama::Template;
27///
28/// #[derive(Template)]
29/// #[template(source = "<h1>{{ title }}</h1>", ext = "html")]
30/// struct Show<'a> {
31///     title: &'a str,
32/// }
33///
34/// let html = ocre::render(&Show { title: "Tom & Jerry" }).unwrap();
35/// assert_eq!(html.0, "<h1>Tom &#38; Jerry</h1>");
36/// ```
37pub fn render<T: Template>(template: &T) -> Result<Html<String>> {
38    Ok(Html(template.render()?))
39}
40
41impl From<askama::Error> for Error {
42    fn from(err: askama::Error) -> Self {
43        Self::Internal(format!("template rendering failed: {err}"))
44    }
45}
46
47/// HTML error page. JSON endpoints return [`ApiError`](crate::ApiError) instead.
48///
49/// The page is a bare `<h1>404</h1><p>Not found</p>`; the response carries
50/// an [`ErrorPage`] extension so [`error_page`] can render the app's own
51/// template instead.
52impl IntoResponse for Error {
53    fn into_response(self) -> Response {
54        let public = self.into_public();
55        let page = ErrorPage { status: public.status, message: public.message, fields: public.fields };
56        let mut response = (page.status, Html(page.default_html())).into_response();
57        response.extensions_mut().insert(page);
58        crate::error::mark(public.internal, &mut response);
59        response
60    }
61}
62
63/// What an HTML error page may show: the status, a message safe for users, and validation errors.
64///
65/// Built for every [`Error`] rendered as HTML (the message of a 500 is
66/// always `Internal server error`; details only go to the logs), and for
67/// bodyless or plain-text error responses such as axum's rejections, with
68/// the status's reason phrase as message. Templates given to [`error_page`]
69/// receive it.
70///
71/// # Examples
72///
73/// ```
74/// use axum::{http::StatusCode, response::IntoResponse};
75/// use ocre::{Error, ErrorPage};
76///
77/// let response = Error::NotFound.into_response();
78/// let page = response.extensions().get::<ErrorPage>().unwrap();
79/// assert_eq!((page.status, page.message.as_str()), (StatusCode::NOT_FOUND, "Not found"));
80/// ```
81#[derive(Debug, Clone)]
82pub struct ErrorPage {
83    /// HTTP status of the response, e.g. `404`: `{{ error.status.as_u16() }}` in a template.
84    pub status: StatusCode,
85    /// Message for the user: `Not found`, `Validation failed`, a bad request's own message...
86    pub message: String,
87    /// Field errors of a 422 (`{{ field.full_message() }}`), empty otherwise.
88    pub fields: Vec<FieldError>,
89}
90
91impl ErrorPage {
92    fn default_html(&self) -> String {
93        let mut page = format!("<h1>{}</h1><p>{}</p>", self.status.as_u16(), escape(&self.message));
94        if !self.fields.is_empty() {
95            page.push_str("<ul>");
96            for field in &self.fields {
97                page.push_str(&format!("<li>{}</li>", escape(&field.full_message())));
98            }
99            page.push_str("</ul>");
100        }
101        page
102    }
103}
104
105/// Renders error responses with the app's own template (Rails' `public/404.html` and `500.html`).
106///
107/// Call it from an [`axum::middleware::map_response`] layer on the app's
108/// router, so every HTML error gets the layout: [`Error`]s returned by
109/// handlers (their [`ErrorPage`]), the 404 of the router's fallback, and
110/// bodyless or plain-text error statuses (axum's rejections, e.g. a path
111/// that does not parse), whose message is the status's reason phrase.
112/// Responses that already have an HTML or JSON body, and success
113/// responses, go out unchanged. The status and headers are kept. When
114/// `render` fails, the error is logged and the plain page is sent. Costs a
115/// template render per error response, no binding call.
116///
117/// `ocre new` generates this in `src/lib.rs`, with `templates/error.html`:
118///
119/// ```no_run
120/// use askama::Template;
121/// use axum::{Router, middleware::map_response, response::{Html, Response}};
122/// use ocre::{Ctx, Error, ErrorPage, Result, render};
123///
124/// fn routes() -> Router<Ctx> {
125///     Router::new()
126///         // ...routes...
127///         .fallback(not_found)
128///         .layer(map_response(error_page))
129/// }
130///
131/// async fn not_found() -> Error {
132///     Error::NotFound
133/// }
134///
135/// #[derive(Template)]
136/// #[template(source = "<h1>{{ error.message }}</h1>", ext = "html")]
137/// struct ErrorView<'a> {
138///     error: &'a ErrorPage,
139/// }
140///
141/// async fn error_page(response: Response) -> Response {
142///     ocre::error_page(response, |error| render(&ErrorView { error }))
143/// }
144/// # let _ = routes;
145/// ```
146///
147/// # Examples
148///
149/// ```
150/// use axum::{http::StatusCode, response::{Html, IntoResponse}};
151/// use ocre::Error;
152///
153/// let custom = |error: &ocre::ErrorPage| Ok(Html(format!("<main>{}</main>", error.message)));
154/// let response = ocre::error_page(Error::NotFound.into_response(), custom);
155/// assert_eq!(response.status(), StatusCode::NOT_FOUND);
156///
157/// let rejected = (StatusCode::BAD_REQUEST, "Invalid URL").into_response();
158/// assert_eq!(ocre::error_page(rejected, custom).status(), StatusCode::BAD_REQUEST);
159/// ```
160pub fn error_page(response: Response, render: impl FnOnce(&ErrorPage) -> Result<Html<String>>) -> Response {
161    let status = response.status();
162    let page = match response.extensions().get::<ErrorPage>() {
163        Some(page) => page.clone(),
164        None if (status.is_client_error() || status.is_server_error()) && !has_rich_body(&response) => {
165            let message = status.canonical_reason().unwrap_or("Error").to_owned();
166            ErrorPage { status, message, fields: vec![] }
167        }
168        None => return response,
169    };
170    let html = render(&page).map_or_else(
171        |err| {
172            crate::error::log_internal(&format!("error page template failed: {err}"));
173            page.default_html()
174        },
175        |Html(html)| html,
176    );
177    let (mut parts, _) = response.into_parts();
178    parts.headers.remove(header::CONTENT_LENGTH);
179    parts.headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
180    parts.extensions.insert(page);
181    Response::from_parts(parts, Body::from(html))
182}
183
184/// Whether the body is HTML or JSON already (a page, an API error): only
185/// bodyless and plain-text errors get the error page.
186fn has_rich_body(response: &Response) -> bool {
187    let content_type = response.headers().get(header::CONTENT_TYPE).and_then(|value| value.to_str().ok());
188    content_type.is_some_and(|value| !value.starts_with("text/plain"))
189}
190
191fn escape(text: &str) -> String {
192    let mut out = String::with_capacity(text.len());
193    for c in text.chars() {
194        match c {
195            '&' => out.push_str("&amp;"),
196            '<' => out.push_str("&lt;"),
197            '>' => out.push_str("&gt;"),
198            '"' => out.push_str("&quot;"),
199            '\'' => out.push_str("&#39;"),
200            _ => out.push(c),
201        }
202    }
203    out
204}
205
206#[cfg(test)]
207#[path = "../tests/view.rs"]
208mod tests;