Skip to main content

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}