Skip to main content

max / quasi

7.7 KB · 215 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 use crate::request::Method;
20
21 /// What kind of failure it was.
22 ///
23 /// Five, and each one is a different thing for a host to do. `NotFound` and
24 /// `Denied` are separate because a host that conflates them cannot answer the
25 /// question its own access log asks. `Conflict` is separate because it is the
26 /// one failure the user can fix by looking at the screen again, which is a
27 /// banner rather than a toast. `Unsupported` is separate because the address
28 /// is real and only the verb is wrong, which is a different sentence and a
29 /// different status code.
30 ///
31 /// `#[non_exhaustive]` for the reason `makeover-layout` puts it on the
32 /// vocabularies renderers match against: growth must not be a lockstep event
33 /// across every host adapter.
34 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35 #[non_exhaustive]
36 pub enum Class {
37 /// The thing addressed is not there.
38 NotFound,
39 /// The thing is there and this caller may not have it.
40 Denied,
41 /// The request was answerable and the current state refuses it.
42 Conflict,
43 /// The address is there and does not take this verb.
44 ///
45 /// Measured against MNW's `/pricing`. Distinct from
46 /// [`NotFound`](Self::NotFound), which says the address does not exist:
47 /// answering that for a page a host serves at that URL tells a crawler the
48 /// page is gone. What does not exist is the verb, and the verbs that do is
49 /// information the caller can act on -- see [`RouteError::allow`].
50 Unsupported,
51 /// We are broken.
52 Internal,
53 }
54
55 impl Class {
56 /// What the failure is saying, in `makeover-layout`'s vocabulary.
57 ///
58 /// Only `Internal` is [`Tone::Danger`]. The other three are conditions the
59 /// user can understand and often act on, and spending the loudest tone on
60 /// all of them is how a `Danger` stops meaning anything.
61 #[must_use]
62 pub const fn tone(self) -> Tone {
63 match self {
64 Self::NotFound | Self::Denied | Self::Conflict | Self::Unsupported => Tone::Warning,
65 Self::Internal => Tone::Danger,
66 }
67 }
68
69 /// The status code an HTTP host answers with.
70 ///
71 /// A convenience rather than a leak: this is a `u16`, no host crate is
72 /// imported to produce it, and every HTTP adapter we will write would
73 /// otherwise hand-roll the same four-arm match. Hosts with no notion of a
74 /// status code ignore it and read [`Class`] directly.
75 #[must_use]
76 pub const fn http_status(self) -> u16 {
77 match self {
78 Self::NotFound => 404,
79 Self::Denied => 403,
80 Self::Unsupported => 405,
81 Self::Conflict => 409,
82 Self::Internal => 500,
83 }
84 }
85
86 /// Whether the failure is ours rather than the caller's.
87 ///
88 /// The line a host logs on. A `NotFound` at volume is a broken link
89 /// somewhere; an `Internal` at any volume is a page.
90 #[must_use]
91 pub const fn is_ours(self) -> bool {
92 matches!(self, Self::Internal)
93 }
94 }
95
96 /// A route that could not answer.
97 ///
98 /// Carries a class for the host and a notice for the screen. The notice
99 /// defaults to [`Notice::Banner`], because a failure is persistent by nature:
100 /// it is dismissed by fixing the condition that caused it, which is exactly
101 /// what `makeover-layout` says a banner is for. Call [`RouteError::as_toast`]
102 /// where the failure really is transient.
103 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
104 pub struct RouteError {
105 /// What kind of failure it was.
106 pub class: Class,
107 /// How the message sits on screen.
108 pub notice: Notice,
109 /// What to tell the user. Already user-facing text, not a debug string.
110 pub message: String,
111 /// The verbs the address does take, for a
112 /// [`Class::Unsupported`](Class::Unsupported).
113 ///
114 /// Empty for every other class, and a caller should read it as "no claim
115 /// is being made" rather than "no verbs". It rides on the error rather
116 /// than being asked of the router afterwards because the two adapters that
117 /// need it are not both holding one: `quasi_http::respond` is handed a
118 /// `Result` and no table.
119 ///
120 /// Sorted and deduplicated by [`unsupported`](Self::unsupported), so
121 /// `Allow` reads the same however the routes were registered.
122 pub allow: Vec<Method>,
123 }
124
125 impl RouteError {
126 /// A failure of the given class, shown as a banner.
127 pub fn new(class: Class, message: impl Into<String>) -> Self {
128 Self {
129 class,
130 notice: Notice::Banner,
131 message: message.into(),
132 allow: Vec::new(),
133 }
134 }
135
136 /// The address is there and does not take this verb.
137 ///
138 /// `allow` is what it does take, and is what an HTTP host puts in the
139 /// header of the same name. Sorted and deduplicated here so that the
140 /// answer does not depend on registration order.
141 pub fn unsupported(
142 message: impl Into<String>,
143 allow: impl IntoIterator<Item = Method>,
144 ) -> Self {
145 let mut allow: Vec<Method> = allow.into_iter().collect();
146 allow.sort_unstable();
147 allow.dedup();
148 Self {
149 allow,
150 ..Self::new(Class::Unsupported, message)
151 }
152 }
153
154 /// The `Allow` header's value, or `None` when nothing is being claimed.
155 ///
156 /// Here rather than in each adapter because both HTTP hosts want the same
157 /// string, and because the comma-and-space spelling is the one RFC 9110
158 /// gives. A host with no notion of a header ignores it and reads
159 /// [`allow`](Self::allow).
160 #[must_use]
161 pub fn allow_header(&self) -> Option<String> {
162 if self.allow.is_empty() {
163 return None;
164 }
165 Some(
166 self.allow
167 .iter()
168 .map(ToString::to_string)
169 .collect::<Vec<_>>()
170 .join(", "),
171 )
172 }
173
174 /// The thing addressed is not there.
175 pub fn not_found(message: impl Into<String>) -> Self {
176 Self::new(Class::NotFound, message)
177 }
178
179 /// The caller may not have it.
180 pub fn denied(message: impl Into<String>) -> Self {
181 Self::new(Class::Denied, message)
182 }
183
184 /// The current state refuses the request.
185 pub fn conflict(message: impl Into<String>) -> Self {
186 Self::new(Class::Conflict, message)
187 }
188
189 /// We are broken.
190 pub fn internal(message: impl Into<String>) -> Self {
191 Self::new(Class::Internal, message)
192 }
193
194 /// The same failure, shown as a toast instead of a banner.
195 #[must_use]
196 pub fn as_toast(mut self) -> Self {
197 self.notice = Notice::Toast;
198 self
199 }
200
201 /// What the failure is saying. Delegates to the class.
202 #[must_use]
203 pub const fn tone(&self) -> Tone {
204 self.class.tone()
205 }
206 }
207
208 impl std::fmt::Display for RouteError {
209 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210 write!(f, "{:?}: {}", self.class, self.message)
211 }
212 }
213
214 impl std::error::Error for RouteError {}
215