web_net/error.rs
1//! Errors: what a handler returns, and what a build refuses.
2//!
3//! Every refusal and error becomes a response through one function: the
4//! status, the site's error template for it (or a built-in page that cannot
5//! fail), a generic message, and the request's reference. The code and the
6//! detail go to the events only.
7
8use std::fmt;
9
10type Source = Box<dyn std::error::Error + Send + Sync>;
11
12/// A handler error: a status, a stable code, and an optional source kept for
13/// the logs. The visitor sees the status, a generic message, and the
14/// request's reference; never the code or the source.
15///
16/// ```
17/// use web_net::Error;
18///
19/// let e = Error::not_found("blog.no_post");
20/// assert_eq!((e.status(), e.code()), (404, "blog.no_post"));
21///
22/// let io = std::io::Error::other("disk gone");
23/// let e = Error::internal("blog.read").with_source(io);
24/// assert_eq!(e.status(), 500);
25/// assert!(e.source_ref().is_some());
26/// ```
27pub struct Error {
28 status: u16,
29 code: &'static str,
30 source: Option<Source>,
31}
32
33impl Error {
34 /// An error with `status` (400 to 599; anything else is treated as 500).
35 ///
36 /// ```
37 /// use web_net::Error;
38 ///
39 /// assert_eq!(Error::new(429, "rate.limited").status(), 429);
40 /// // A status outside 400 to 599 is treated as 500.
41 /// assert_eq!(Error::new(200, "not.an.error").status(), 500);
42 /// ```
43 #[must_use]
44 pub fn new(status: u16, code: &'static str) -> Self {
45 let status = if (400..=599).contains(&status) {
46 status
47 } else {
48 500
49 };
50 Self {
51 status,
52 code,
53 source: None,
54 }
55 }
56
57 /// 400 Bad Request.
58 ///
59 /// ```
60 /// let e = web_net::Error::bad_request("search.no_query");
61 /// assert_eq!((e.status(), e.code()), (400, "search.no_query"));
62 /// ```
63 #[must_use]
64 pub fn bad_request(code: &'static str) -> Self {
65 Self::new(400, code)
66 }
67
68 /// 403 Forbidden.
69 ///
70 /// ```
71 /// use web_net::{App, Error, get};
72 ///
73 /// let app = App::builder()
74 /// .route(get("/admin", |_cx| Err(Error::forbidden("admin.closed"))))
75 /// .build()?;
76 /// let r = serve_net::testing::respond(&app, b"GET /admin HTTP/1.1\r\nHost: localhost\r\n\r\n");
77 /// assert_eq!(r.status, 403);
78 /// # Ok::<(), web_net::BuildError>(())
79 /// ```
80 #[must_use]
81 pub fn forbidden(code: &'static str) -> Self {
82 Self::new(403, code)
83 }
84
85 /// 404 Not Found.
86 ///
87 /// ```
88 /// let e = web_net::Error::not_found("blog.no_post");
89 /// assert_eq!((e.status(), e.code()), (404, "blog.no_post"));
90 /// ```
91 #[must_use]
92 pub fn not_found(code: &'static str) -> Self {
93 Self::new(404, code)
94 }
95
96 /// 409 Conflict.
97 ///
98 /// ```
99 /// let e = web_net::Error::conflict("user.name_taken");
100 /// assert_eq!((e.status(), e.code()), (409, "user.name_taken"));
101 /// ```
102 #[must_use]
103 pub fn conflict(code: &'static str) -> Self {
104 Self::new(409, code)
105 }
106
107 /// 422 Unprocessable Content: the request was understood and refused
108 /// (a form with errors).
109 ///
110 /// ```
111 /// let e = web_net::Error::unprocessable("signup.weak_password");
112 /// assert_eq!((e.status(), e.code()), (422, "signup.weak_password"));
113 /// ```
114 #[must_use]
115 pub fn unprocessable(code: &'static str) -> Self {
116 Self::new(422, code)
117 }
118
119 /// 500 Internal Server Error: our fault, not the visitor's.
120 ///
121 /// ```
122 /// let e = web_net::Error::internal("report.parse");
123 /// assert_eq!((e.status(), e.code()), (500, "report.parse"));
124 /// ```
125 #[must_use]
126 pub fn internal(code: &'static str) -> Self {
127 Self::new(500, code)
128 }
129
130 /// 503 Service Unavailable.
131 ///
132 /// ```
133 /// let e = web_net::Error::unavailable("db.down");
134 /// assert_eq!((e.status(), e.code()), (503, "db.down"));
135 /// ```
136 #[must_use]
137 pub fn unavailable(code: &'static str) -> Self {
138 Self::new(503, code)
139 }
140
141 /// Keeps `source` for the logs.
142 ///
143 /// ```
144 /// use web_net::Error;
145 ///
146 /// let io = std::io::Error::other("no such file");
147 /// let e = Error::internal("config.read").with_source(io);
148 /// assert_eq!(e.source_ref().map(|s| s.to_string()), Some("no such file".to_owned()));
149 /// ```
150 #[must_use]
151 pub fn with_source(mut self, source: impl Into<Source>) -> Self {
152 self.source = Some(source.into());
153 self
154 }
155
156 /// The status.
157 ///
158 /// ```
159 /// assert_eq!(web_net::Error::conflict("cart.changed").status(), 409);
160 /// ```
161 #[must_use]
162 pub const fn status(&self) -> u16 {
163 self.status
164 }
165
166 /// The code.
167 ///
168 /// ```
169 /// assert_eq!(web_net::Error::not_found("blog.no_post").code(), "blog.no_post");
170 /// ```
171 #[must_use]
172 pub const fn code(&self) -> &'static str {
173 self.code
174 }
175
176 /// The source, if one was kept.
177 ///
178 /// ```
179 /// use web_net::Error;
180 ///
181 /// assert!(Error::internal("x").source_ref().is_none());
182 /// let e = Error::internal("x").with_source(std::io::Error::other("disk gone"));
183 /// assert!(e.source_ref().is_some());
184 /// ```
185 #[must_use]
186 pub fn source_ref(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
187 self.source.as_deref()
188 }
189}
190
191impl fmt::Debug for Error {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 f.debug_struct("Error")
194 .field("status", &self.status)
195 .field("code", &self.code)
196 .field("source", &self.source.as_ref().map(|s| s.to_string()))
197 .finish()
198 }
199}
200
201impl fmt::Display for Error {
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 write!(f, "{} {}", self.status, self.code)?;
204 if let Some(s) = &self.source {
205 write!(f, ": {s}")?;
206 }
207 Ok(())
208 }
209}
210
211impl std::error::Error for Error {
212 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
213 self.source
214 .as_deref()
215 .map(|s| s as &(dyn std::error::Error + 'static))
216 }
217}
218
219impl From<crate::Denial> for Error {
220 fn from(d: crate::Denial) -> Self {
221 Self::new(d.status(), d.code())
222 }
223}
224
225impl From<form_net::FormError> for Error {
226 /// The form's status (400, 413, 415, 422, or 403 for a token) and code.
227 fn from(e: form_net::FormError) -> Self {
228 Self::new(e.status_hint(), e.code()).with_source(e)
229 }
230}
231
232impl From<crate::RenderError> for Error {
233 fn from(e: crate::RenderError) -> Self {
234 Self::internal(e.code()).with_source(e)
235 }
236}
237
238/// The handler result.
239///
240/// ```
241/// use web_net::{App, Context, Error, Guest, Result, get};
242///
243/// fn show(cx: &mut Context<'_, Guest>) -> Result {
244/// let id: u32 = cx.param_as("id")?;
245/// if id == 0 {
246/// return Err(Error::not_found("post.none"));
247/// }
248/// cx.text(200, "a post")
249/// }
250///
251/// let app = App::builder().route(get("/posts/:id", show)).build()?;
252/// let r = serve_net::testing::respond(&app, b"GET /posts/0 HTTP/1.1\r\nHost: localhost\r\n\r\n");
253/// assert_eq!(r.status, 404);
254/// # Ok::<(), web_net::BuildError>(())
255/// ```
256pub type Result<T = (), E = Error> = std::result::Result<T, E>;
257
258/// One thing wrong with an application's declaration.
259///
260/// ```
261/// let err = web_net::App::builder().base("blog").build().unwrap_err();
262/// let p = &err.problems()[0];
263/// assert_eq!(p.subject, "base");
264/// assert!(p.message.contains("/blog"));
265/// ```
266#[non_exhaustive]
267#[derive(Clone, Debug, PartialEq, Eq)]
268pub struct Problem {
269 /// The route or setting it is about (`"GET /admin"`, `"host"`).
270 pub subject: String,
271 /// What is wrong and what to do.
272 pub message: String,
273}
274
275/// Why [`Builder::build`](crate::Builder::build) refused: every problem
276/// found, not only the first.
277///
278/// ```
279/// let err = web_net::App::builder().base("blog").https_only().build().unwrap_err();
280/// assert!(err.to_string().starts_with("the application has 2 problem(s)"));
281/// ```
282#[derive(Clone, Debug, PartialEq, Eq)]
283pub struct BuildError {
284 problems: Vec<Problem>,
285}
286
287impl BuildError {
288 pub(crate) fn new(problems: Vec<Problem>) -> Self {
289 Self { problems }
290 }
291
292 /// The problems, in declaration order.
293 ///
294 /// ```
295 /// let err = web_net::App::builder().base("blog").https_only().build().unwrap_err();
296 /// let subjects: Vec<&str> = err.problems().iter().map(|p| p.subject.as_str()).collect();
297 /// assert_eq!(subjects.len(), 2);
298 /// assert!(subjects.contains(&"base"));
299 /// ```
300 #[must_use]
301 pub fn problems(&self) -> &[Problem] {
302 &self.problems
303 }
304}
305
306impl fmt::Display for BuildError {
307 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308 write!(f, "the application has {} problem(s):", self.problems.len())?;
309 for p in &self.problems {
310 write!(f, "\n {}: {}", p.subject, p.message)?;
311 }
312 Ok(())
313 }
314}
315
316impl std::error::Error for BuildError {}
317
318/// The generic reason phrase shown for a status.
319pub(crate) fn title(status: u16) -> &'static str {
320 match status {
321 400 => "Bad Request",
322 401 => "Unauthorized",
323 403 => "Forbidden",
324 404 => "Not Found",
325 405 => "Method Not Allowed",
326 409 => "Conflict",
327 410 => "Gone",
328 413 => "Content Too Large",
329 415 => "Unsupported Media Type",
330 421 => "Misdirected Request",
331 422 => "Unprocessable Content",
332 429 => "Too Many Requests",
333 500 => "Internal Server Error",
334 501 => "Not Implemented",
335 503 => "Service Unavailable",
336 s if s < 500 => "Request Refused",
337 _ => "Server Error",
338 }
339}
340
341/// The generic message shown for a status.
342pub(crate) fn message(status: u16) -> &'static str {
343 match status {
344 400 => "The request could not be understood.",
345 401 => "Please sign in to continue.",
346 403 => "You do not have access to this page.",
347 404 => "The page was not found.",
348 405 => "This page does not accept that kind of request.",
349 421 => "This server does not serve that address.",
350 422 => "The request could not be processed.",
351 429 => "Too many requests. Please wait a moment and try again.",
352 503 => "The service is unavailable right now. Please try again shortly.",
353 s if s < 500 => "The request was refused.",
354 _ => "Something went wrong on our side.",
355 }
356}
357
358/// The built-in error page: fixed markup, the status, its title and message,
359/// and the reference. Nothing from the request appears in it except the
360/// reference, which web-net makes itself.
361pub(crate) fn builtin_page(status: u16, reference: &str, out: &mut Vec<u8>) {
362 let t = title(status);
363 out.extend_from_slice(
364 b"<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><title>",
365 );
366 push_status(status, out);
367 out.push(b' ');
368 out.extend_from_slice(t.as_bytes());
369 out.extend_from_slice(
370 b"</title><meta name=\"viewport\" content=\"width=device-width\"></head>\n<body><h1>",
371 );
372 out.extend_from_slice(t.as_bytes());
373 out.extend_from_slice(b"</h1><p>");
374 out.extend_from_slice(message(status).as_bytes());
375 out.extend_from_slice(b"</p><p><small>Reference: ");
376 out.extend_from_slice(reference.as_bytes());
377 out.extend_from_slice(b"</small></p></body></html>\n");
378}
379
380/// The error reply for a client that prefers JSON: the same facts as the
381/// page, no detail.
382pub(crate) fn json_body(status: u16, reference: &str, out: &mut Vec<u8>) {
383 out.extend_from_slice(b"{\"status\":");
384 push_status(status, out);
385 out.extend_from_slice(b",\"title\":\"");
386 out.extend_from_slice(title(status).as_bytes());
387 out.extend_from_slice(b"\",\"message\":\"");
388 out.extend_from_slice(message(status).as_bytes());
389 out.extend_from_slice(b"\",\"reference\":\"");
390 // The reference is hex digits, a dash, and digits: nothing to escape.
391 out.extend_from_slice(reference.as_bytes());
392 out.extend_from_slice(b"\"}");
393}
394
395fn push_status(status: u16, out: &mut Vec<u8>) {
396 let s = status.min(999);
397 out.push(b'0' + (s / 100) as u8);
398 out.push(b'0' + (s / 10 % 10) as u8);
399 out.push(b'0' + (s % 10) as u8);
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn test_status_clamped() {
408 assert_eq!(Error::new(200, "x").status(), 500);
409 assert_eq!(Error::new(302, "x").status(), 500);
410 assert_eq!(Error::new(418, "x").status(), 418);
411 }
412
413 #[test]
414 fn test_builtin_page_has_no_detail() {
415 let mut out = Vec::new();
416 builtin_page(404, "c7-r2", &mut out);
417 let s = String::from_utf8(out).unwrap();
418 assert!(s.contains("<title>404 Not Found</title>"));
419 assert!(s.contains("Reference: c7-r2"));
420 }
421
422 #[test]
423 fn test_build_error_lists_all() {
424 let e = BuildError::new(vec![
425 Problem {
426 subject: "GET /".into(),
427 message: "a".into(),
428 },
429 Problem {
430 subject: "GET /b".into(),
431 message: "b".into(),
432 },
433 ]);
434 assert_eq!(e.problems().len(), 2);
435 assert!(e.to_string().contains("2 problem(s)"));
436 }
437}