Skip to main content

max / quasi

5.1 KB · 151 lines History Blame Raw
1 //! Failure, classified once so every host renders it the same way.
2 //!
3 //! Decision 9 on the wiki note. A handler that cannot do what was asked returns
4 //! a [`RouteError`] rather than a [`Response`](crate::Response) describing the
5 //! problem. Two things fall out of that, and both are the reason the type
6 //! exists.
7 //!
8 //! A class is not a message. The class is what the *host* acts on: axum turns
9 //! it into a status code, the terminal and egui turn it into a banner. Handing
10 //! back a plain response instead would have hosted axum answer 200 to
11 //! everything, so caches, logs and monitoring could not tell a denied action
12 //! from a completed one.
13 //!
14 //! The message is already UI. It carries a `makeover-layout` [`Notice`], so
15 //! errors become something on screen in one place rather than once per handler.
16
17 use makeover_layout::{Notice, Tone};
18
19 /// What kind of failure it was.
20 ///
21 /// Four, and each one is a different thing for a host to do. `NotFound` and
22 /// `Denied` are separate because a host that conflates them cannot answer the
23 /// question its own access log asks. `Conflict` is separate because it is the
24 /// one failure the user can fix by looking at the screen again, which is a
25 /// banner rather than a toast.
26 ///
27 /// `#[non_exhaustive]` for the reason `makeover-layout` puts it on the
28 /// vocabularies renderers match against: growth must not be a lockstep event
29 /// across every host adapter.
30 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31 #[non_exhaustive]
32 pub enum Class {
33 /// The thing addressed is not there.
34 NotFound,
35 /// The thing is there and this caller may not have it.
36 Denied,
37 /// The request was answerable and the current state refuses it.
38 Conflict,
39 /// We are broken.
40 Internal,
41 }
42
43 impl Class {
44 /// What the failure is saying, in `makeover-layout`'s vocabulary.
45 ///
46 /// Only `Internal` is [`Tone::Danger`]. The other three are conditions the
47 /// user can understand and often act on, and spending the loudest tone on
48 /// all of them is how a `Danger` stops meaning anything.
49 #[must_use]
50 pub const fn tone(self) -> Tone {
51 match self {
52 Self::NotFound | Self::Denied | Self::Conflict => Tone::Warning,
53 Self::Internal => Tone::Danger,
54 }
55 }
56
57 /// The status code an HTTP host answers with.
58 ///
59 /// A convenience rather than a leak: this is a `u16`, no host crate is
60 /// imported to produce it, and every HTTP adapter we will write would
61 /// otherwise hand-roll the same four-arm match. Hosts with no notion of a
62 /// status code ignore it and read [`Class`] directly.
63 #[must_use]
64 pub const fn http_status(self) -> u16 {
65 match self {
66 Self::NotFound => 404,
67 Self::Denied => 403,
68 Self::Conflict => 409,
69 Self::Internal => 500,
70 }
71 }
72
73 /// Whether the failure is ours rather than the caller's.
74 ///
75 /// The line a host logs on. A `NotFound` at volume is a broken link
76 /// somewhere; an `Internal` at any volume is a page.
77 #[must_use]
78 pub const fn is_ours(self) -> bool {
79 matches!(self, Self::Internal)
80 }
81 }
82
83 /// A route that could not answer.
84 ///
85 /// Carries a class for the host and a notice for the screen. The notice
86 /// defaults to [`Notice::Banner`], because a failure is persistent by nature:
87 /// it is dismissed by fixing the condition that caused it, which is exactly
88 /// what `makeover-layout` says a banner is for. Call [`RouteError::as_toast`]
89 /// where the failure really is transient.
90 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
91 pub struct RouteError {
92 /// What kind of failure it was.
93 pub class: Class,
94 /// How the message sits on screen.
95 pub notice: Notice,
96 /// What to tell the user. Already user-facing text, not a debug string.
97 pub message: String,
98 }
99
100 impl RouteError {
101 /// A failure of the given class, shown as a banner.
102 pub fn new(class: Class, message: impl Into<String>) -> Self {
103 Self {
104 class,
105 notice: Notice::Banner,
106 message: message.into(),
107 }
108 }
109
110 /// The thing addressed is not there.
111 pub fn not_found(message: impl Into<String>) -> Self {
112 Self::new(Class::NotFound, message)
113 }
114
115 /// The caller may not have it.
116 pub fn denied(message: impl Into<String>) -> Self {
117 Self::new(Class::Denied, message)
118 }
119
120 /// The current state refuses the request.
121 pub fn conflict(message: impl Into<String>) -> Self {
122 Self::new(Class::Conflict, message)
123 }
124
125 /// We are broken.
126 pub fn internal(message: impl Into<String>) -> Self {
127 Self::new(Class::Internal, message)
128 }
129
130 /// The same failure, shown as a toast instead of a banner.
131 #[must_use]
132 pub fn as_toast(mut self) -> Self {
133 self.notice = Notice::Toast;
134 self
135 }
136
137 /// What the failure is saying. Delegates to the class.
138 #[must_use]
139 pub const fn tone(&self) -> Tone {
140 self.class.tone()
141 }
142 }
143
144 impl std::fmt::Display for RouteError {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 write!(f, "{:?}: {}", self.class, self.message)
147 }
148 }
149
150 impl std::error::Error for RouteError {}
151