error messages

This commit is contained in:
Priec
2026-08-15 15:08:35 +02:00
parent d0bcdb1b7c
commit fe3848c453
10 changed files with 395 additions and 348 deletions

View File

@@ -5,10 +5,96 @@
//! `templates/<the page's path under src>/`.
use askama::Template;
use axum::http::HeaderMap;
use axum::{
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
};
pub(crate) const SESSION_COOKIE: &str = "analytics_token";
/// Why a form submission failed.
///
/// The status a failure answers with is decided here, once, by *what went
/// wrong* — never by which handler happened to notice. Handlers used to pick
/// their own, and the same class of failure ended up as 200 on one form, 422
/// on another and 502 on a third; the browser hid it, because
/// `ui/base.html` swaps every response body regardless of status.
pub(crate) enum FormError {
/// The submission was understood and rejected: the user has to change
/// something before it can succeed. Whether this crate spotted it while
/// building the request or the backend spotted it afterwards is an
/// implementation detail of *when*, not a difference in kind.
Rejected(String),
/// The session is gone. Nothing is rendered; the browser goes to /login.
Unauthenticated,
/// Signed in, but not allowed to do this.
Forbidden(String),
/// Nothing wrong with the submission — the backend could not answer.
Unavailable(String),
}
impl FormError {
/// Classifies a gRPC failure by its code. The blanket `Err(_) =>` arms
/// this replaces reported an unreachable backend as a rejected submission
/// on some pages and a rejected submission as an unreachable backend on
/// others.
pub(crate) fn from_status(status: &tonic::Status) -> Self {
let message = status.message().to_string();
match status.code() {
tonic::Code::Unauthenticated => Self::Unauthenticated,
tonic::Code::PermissionDenied => Self::Forbidden(message),
// Everything the caller can fix by editing the form: a value the
// server rejected, a name already taken, a row that does not
// satisfy the table's own rules.
tonic::Code::InvalidArgument
| tonic::Code::FailedPrecondition
| tonic::Code::OutOfRange
| tonic::Code::AlreadyExists
| tonic::Code::NotFound => Self::Rejected(message),
_ => Self::Unavailable(message),
}
}
pub(crate) fn status_code(&self) -> StatusCode {
match self {
// Understood, and refused. Not 400: the request parsed fine.
Self::Rejected(_) => StatusCode::UNPROCESSABLE_ENTITY,
Self::Unauthenticated => StatusCode::UNAUTHORIZED,
Self::Forbidden(_) => StatusCode::FORBIDDEN,
Self::Unavailable(_) => StatusCode::BAD_GATEWAY,
}
}
pub(crate) fn message(&self) -> &str {
match self {
Self::Rejected(message) | Self::Forbidden(message) | Self::Unavailable(message) => {
message
}
Self::Unauthenticated => "",
}
}
/// The response, rendered through the page's own alert renderer so each
/// form keeps its own error title.
///
/// The body is always the alert *fragment*, because that is what a form's
/// `hx-target` is: a status block. Re-rendering the whole page here — as
/// the locally-detected errors used to — sends a complete `<html>`
/// document to be swapped into a `<div>` inside the page that is already
/// open.
pub(crate) fn into_response(
self,
locale: crate::i18n::Locale,
render_alert: fn(crate::i18n::Locale, &str) -> String,
) -> Response {
if matches!(self, Self::Unauthenticated) {
return Redirect::to("/login").into_response();
}
let status = self.status_code();
(status, Html(render_alert(locale, self.message()))).into_response()
}
}
/// Navbar state. Every full-page template struct carries one of these, because
/// `ui/base.html` renders `ui/navbar.html` unconditionally.
#[derive(Clone, Debug)]
@@ -268,6 +354,52 @@ pub(crate) fn render<T: Template>(template: &T) -> String {
mod tests {
use super::*;
/// The status is a property of what went wrong, and a gRPC failure knows
/// which it was. The blanket arms this replaced answered 422 for an
/// unreachable backend on one page and 502 for a rejected row on another.
#[test]
fn a_backend_failure_is_classified_by_its_code() {
let rejected = FormError::from_status(&tonic::Status::invalid_argument(
"Script calculated '3', but user provided '99'",
));
assert_eq!(rejected.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
let unreachable =
FormError::from_status(&tonic::Status::unavailable("connection refused"));
assert_eq!(unreachable.status_code(), StatusCode::BAD_GATEWAY);
let forbidden =
FormError::from_status(&tonic::Status::permission_denied("not your profile"));
assert_eq!(forbidden.status_code(), StatusCode::FORBIDDEN);
assert!(matches!(
FormError::from_status(&tonic::Status::unauthenticated("expired")),
FormError::Unauthenticated
));
}
/// A submission refused here and a submission refused by the backend are
/// the same event to the user, so they are the same response: 422, and the
/// alert *fragment* the form's `hx-target` expects — never a whole page.
#[test]
fn a_rejection_answers_with_a_fragment_and_not_a_document() {
fn alert(locale: crate::i18n::Locale, message: &str) -> String {
render(&Alert::error(locale, "Could not save", message))
}
let response = FormError::Rejected("Pattern line 1 is malformed.".to_string())
.into_response(crate::i18n::Locale::default(), alert);
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
// What that response carries. A form targets a status block, so the
// body has to be a fragment: the locally-detected errors used to
// answer with a re-rendered page, i.e. a whole document to be swapped
// into a `<div>` inside the document already open.
let body = alert(crate::i18n::Locale::default(), "Pattern line 1 is malformed.");
assert!(!body.contains("<html"), "{body}");
assert!(!body.contains("<!doctype"), "{body}");
}
/// The messages rendered `|safe` carry their own markup, so Askama does no
/// escaping for them at all — which would make an interpolated value a way
/// to write HTML into the page. The value is escaped here instead.