//! Failure, classified once so every host renders it the same way. //! //! Decision 9 on the wiki note. A handler that cannot do what was asked returns //! a [`RouteError`] rather than a [`Response`](crate::Response) describing the //! problem. Two things fall out of that, and both are the reason the type //! exists. //! //! A class is not a message. The class is what the *host* acts on: axum turns //! it into a status code, the terminal and egui turn it into a banner. Handing //! back a plain response instead would have hosted axum answer 200 to //! everything, so caches, logs and monitoring could not tell a denied action //! from a completed one. //! //! The message is already UI. It carries a `makeover-layout` [`Notice`], so //! errors become something on screen in one place rather than once per handler. use makeover_layout::{Notice, Tone}; use crate::request::Method; /// What kind of failure it was. /// /// Five, and each one is a different thing for a host to do. `NotFound` and /// `Denied` are separate because a host that conflates them cannot answer the /// question its own access log asks. `Conflict` is separate because it is the /// one failure the user can fix by looking at the screen again, which is a /// banner rather than a toast. `Unsupported` is separate because the address /// is real and only the verb is wrong, which is a different sentence and a /// different status code. /// /// `#[non_exhaustive]` for the reason `makeover-layout` puts it on the /// vocabularies renderers match against: growth must not be a lockstep event /// across every host adapter. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum Class { /// The thing addressed is not there. NotFound, /// The thing is there and this caller may not have it. Denied, /// The request was answerable and the current state refuses it. Conflict, /// The address is there and does not take this verb. /// /// Measured against MNW's `/pricing`. Distinct from /// [`NotFound`](Self::NotFound), which says the address does not exist: /// answering that for a page a host serves at that URL tells a crawler the /// page is gone. What does not exist is the verb, and the verbs that do is /// information the caller can act on -- see [`RouteError::allow`]. Unsupported, /// We are broken. Internal, } impl Class { /// What the failure is saying, in `makeover-layout`'s vocabulary. /// /// Only `Internal` is [`Tone::Danger`]. The other three are conditions the /// user can understand and often act on, and spending the loudest tone on /// all of them is how a `Danger` stops meaning anything. #[must_use] pub const fn tone(self) -> Tone { match self { Self::NotFound | Self::Denied | Self::Conflict | Self::Unsupported => Tone::Warning, Self::Internal => Tone::Danger, } } /// The status code an HTTP host answers with. /// /// A convenience rather than a leak: this is a `u16`, no host crate is /// imported to produce it, and every HTTP adapter we will write would /// otherwise hand-roll the same four-arm match. Hosts with no notion of a /// status code ignore it and read [`Class`] directly. #[must_use] pub const fn http_status(self) -> u16 { match self { Self::NotFound => 404, Self::Denied => 403, Self::Unsupported => 405, Self::Conflict => 409, Self::Internal => 500, } } /// Whether the failure is ours rather than the caller's. /// /// The line a host logs on. A `NotFound` at volume is a broken link /// somewhere; an `Internal` at any volume is a page. #[must_use] pub const fn is_ours(self) -> bool { matches!(self, Self::Internal) } } /// A route that could not answer. /// /// Carries a class for the host and a notice for the screen. The notice /// defaults to [`Notice::Banner`], because a failure is persistent by nature: /// it is dismissed by fixing the condition that caused it, which is exactly /// what `makeover-layout` says a banner is for. Call [`RouteError::as_toast`] /// where the failure really is transient. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RouteError { /// What kind of failure it was. pub class: Class, /// How the message sits on screen. pub notice: Notice, /// What to tell the user. Already user-facing text, not a debug string. pub message: String, /// The verbs the address does take, for a /// [`Class::Unsupported`](Class::Unsupported). /// /// Empty for every other class, and a caller should read it as "no claim /// is being made" rather than "no verbs". It rides on the error rather /// than being asked of the router afterwards because the two adapters that /// need it are not both holding one: `quasi_http::respond` is handed a /// `Result` and no table. /// /// Sorted and deduplicated by [`unsupported`](Self::unsupported), so /// `Allow` reads the same however the routes were registered. pub allow: Vec, } impl RouteError { /// A failure of the given class, shown as a banner. pub fn new(class: Class, message: impl Into) -> Self { Self { class, notice: Notice::Banner, message: message.into(), allow: Vec::new(), } } /// The address is there and does not take this verb. /// /// `allow` is what it does take, and is what an HTTP host puts in the /// header of the same name. Sorted and deduplicated here so that the /// answer does not depend on registration order. pub fn unsupported( message: impl Into, allow: impl IntoIterator, ) -> Self { let mut allow: Vec = allow.into_iter().collect(); allow.sort_unstable(); allow.dedup(); Self { allow, ..Self::new(Class::Unsupported, message) } } /// The `Allow` header's value, or `None` when nothing is being claimed. /// /// Here rather than in each adapter because both HTTP hosts want the same /// string, and because the comma-and-space spelling is the one RFC 9110 /// gives. A host with no notion of a header ignores it and reads /// [`allow`](Self::allow). #[must_use] pub fn allow_header(&self) -> Option { if self.allow.is_empty() { return None; } Some( self.allow .iter() .map(ToString::to_string) .collect::>() .join(", "), ) } /// The thing addressed is not there. pub fn not_found(message: impl Into) -> Self { Self::new(Class::NotFound, message) } /// The caller may not have it. pub fn denied(message: impl Into) -> Self { Self::new(Class::Denied, message) } /// The current state refuses the request. pub fn conflict(message: impl Into) -> Self { Self::new(Class::Conflict, message) } /// We are broken. pub fn internal(message: impl Into) -> Self { Self::new(Class::Internal, message) } /// The same failure, shown as a toast instead of a banner. #[must_use] pub fn as_toast(mut self) -> Self { self.notice = Notice::Toast; self } /// What the failure is saying. Delegates to the class. #[must_use] pub const fn tone(&self) -> Tone { self.class.tone() } } impl std::fmt::Display for RouteError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{:?}: {}", self.class, self.message) } } impl std::error::Error for RouteError {}