error messages
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user