lib.rsannotatedlib.rssource157 lines · 4.8 KB · raw
1//! What a site writes to its log, and the rule that it never writes a visitor.
2//!
3//! A line is built only from closed types: the **route** (one of a fixed set the site declares, never
4//! the path that was asked for), the method (one of four words), the status and how long it took. There
5//! is no way to hand [`Served`] an address, a header, a query or a body, so none can be logged by
6//! accident. A site never reads the client's address for its log at all.
7//!
8//! Levels follow who was wrong: `Error` for a response the site got wrong (5xx), `Warn` for one the
9//! visitor got wrong or a file that should exist and does not (4xx), `Info` for a page served and
10//! `Debug` for the small things a page asks for. A route may say otherwise for a status.
11//!
12//! No runtime: an adapter writes [`Served`] to the console (the Worker's) or to a logger.
13
14use std::fmt;
15
16/// A site's routes: a fixed set of names a path is reduced to, once, at the edge.
17pub trait Route: Copy {
18    /// The name written to the log.
19    fn name(self) -> &'static str;
20
21    /// A level this route gives this status, overriding the default; `None` for the default.
22    fn level(self, _status: u16) -> Option<Level> {
23        None
24    }
25
26    /// Words appended to the line for this route and status, for a failure only the site's own
27    /// build can cause (a file that was never generated). Never a request's content.
28    fn note(self, _status: u16) -> Option<&'static str> {
29        None
30    }
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
34pub enum Level {
35    Debug,
36    Info,
37    Warn,
38    Error,
39}
40
41/// The method as one of four words, so a made-up one cannot reach the log.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum Method {
44    Get,
45    Head,
46    Post,
47    Other,
48}
49
50impl Method {
51    pub fn parse(method: &str) -> Method {
52        match method {
53            "GET" => Method::Get,
54            "HEAD" => Method::Head,
55            "POST" => Method::Post,
56            _ => Method::Other,
57        }
58    }
59
60    fn word(self) -> &'static str {
61        match self {
62            Method::Get => "GET",
63            Method::Head => "HEAD",
64            Method::Post => "POST",
65            Method::Other => "OTHER",
66        }
67    }
68}
69
70/// One request served.
71#[derive(Clone, Copy, Debug)]
72pub struct Served<R: Route> {
73    pub route: R,
74    pub method: Method,
75    pub status: u16,
76    pub millis: u64,
77}
78
79impl<R: Route> Served<R> {
80    pub fn level(&self) -> Level {
81        if self.status >= 500 {
82            return Level::Error;
83        }
84        if let Some(level) = self.route.level(self.status) {
85            return level;
86        }
87        if (400..=499).contains(&self.status) { Level::Warn } else { Level::Debug }
88    }
89}
90
91impl<R: Route> fmt::Display for Served<R> {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        write!(f, "{} {} -> {} in {} ms", self.method.word(), self.route.name(), self.status, self.millis)?;
94        if let Some(note) = self.route.note(self.status) {
95            write!(f, " ({note})")?;
96        }
97        Ok(())
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[derive(Clone, Copy)]
106    enum R {
107        Page,
108        Asset,
109        Missing,
110    }
111
112    impl Route for R {
113        fn name(self) -> &'static str {
114            match self {
115                R::Page => "page",
116                R::Asset => "asset",
117                R::Missing => "missing",
118            }
119        }
120
121        fn level(self, status: u16) -> Option<Level> {
122            match (self, status) {
123                (R::Page, 200) => Some(Level::Info),
124                (R::Asset, 404) => Some(Level::Debug),
125                (R::Missing, _) => Some(Level::Warn),
126                _ => None,
127            }
128        }
129
130        fn note(self, _status: u16) -> Option<&'static str> {
131            matches!(self, R::Missing).then_some("run the build")
132        }
133    }
134
135    fn served(route: R, status: u16) -> Served<R> {
136        Served { route, method: Method::Get, status, millis: 3 }
137    }
138
139    #[test]
140    fn levels_follow_who_was_wrong() {
141        assert_eq!(served(R::Page, 200).level(), Level::Info);
142        assert_eq!(served(R::Asset, 200).level(), Level::Debug);
143        assert_eq!(served(R::Asset, 404).level(), Level::Debug);
144        assert_eq!(served(R::Page, 404).level(), Level::Warn);
145        assert_eq!(served(R::Missing, 404).level(), Level::Warn);
146        assert_eq!(served(R::Page, 500).level(), Level::Error);
147        assert_eq!(served(R::Missing, 503).level(), Level::Error, "a 5xx is the site's, whatever the route says");
148    }
149
150    #[test]
151    fn a_line_says_what_happened_and_nothing_about_who_asked() {
152        assert_eq!(served(R::Asset, 200).to_string(), "GET asset -> 200 in 3 ms");
153        assert_eq!(served(R::Missing, 404).to_string(), "GET missing -> 404 in 3 ms (run the build)");
154        assert_eq!(Method::parse("BREW"), Method::Other);
155        assert_eq!(Method::parse("POST"), Method::Post);
156    }
157}