Skip to main content

rustlavel_debugbar/
plugin.rs

1//! Turning the bar on, and the rules about when it may appear.
2
3use crate::collector::Collector;
4use crate::render;
5use rustlavel_core::Config;
6use rustlavel_http::plugin::{Plugin, Setup};
7use rustlavel_http::{Middleware, Next, Request, Response};
8use std::future::Future;
9use std::pin::Pin;
10
11/// The development overlay.
12///
13/// | Config key          | Meaning                                        |
14/// |---------------------|------------------------------------------------|
15/// | `debugbar.enabled`  | Show it at all. Defaults to false in production.|
16#[derive(Default)]
17pub struct DebugBar {
18    even_in_production: bool,
19}
20
21impl DebugBar {
22    pub fn new() -> DebugBar {
23        DebugBar::default()
24    }
25
26    /// Show the bar in production too.
27    ///
28    /// Spelled out in full because of what it means: the bar writes SQL, cache
29    /// keys and timings into the HTML of every page, where a browser, a
30    /// screenshot and a bug report will all pick them up. There is no
31    /// authentication in front of it, because it is not a page — it is part of
32    /// yours.
33    pub fn even_in_production(mut self) -> DebugBar {
34        self.even_in_production = true;
35        self
36    }
37
38    fn wanted(&self, config: &Config) -> bool {
39        config.bool("debugbar.enabled", !config.is_production() || self.even_in_production)
40    }
41}
42
43impl Plugin for DebugBar {
44    fn name(&self) -> &'static str {
45        "debugbar"
46    }
47
48    fn register(self: Box<Self>, setup: &mut Setup<'_>) {
49        if !self.wanted(setup.config) {
50            return;
51        }
52        if setup.config.is_production() {
53            rustlavel_core::warn!(
54                "debugbar: on in production. Every page now carries its own SQL and timings, \
55                 to anyone who views source."
56            );
57        }
58
59        Collector::install();
60        setup.router.middleware(Inject);
61    }
62}
63
64/// Collects for the request, then writes the bar into the page it produced.
65struct Inject;
66
67impl Middleware for Inject {
68    fn handle(&self, request: Request, next: Next) -> Pin<Box<dyn Future<Output = Response> + Send>> {
69        Box::pin(async move {
70            let route = request.path().to_string();
71            let (mut response, collected) = Collector::collect(next.run(request)).await;
72
73            if !is_a_page(&response) {
74                return response;
75            }
76
77            let body = response.body_string();
78            let bar = render::bar(&collected, &route, response.status.code());
79
80            // Before `</body>`, and only if there is one. A fragment returned to
81            // a fetch() has no closing tag, and appending to it would put a
82            // debug bar inside whatever element the page dropped it into.
83            let Some(at) = body.to_lowercase().rfind("</body>") else {
84                return response;
85            };
86
87            let mut page = String::with_capacity(body.len() + bar.len());
88            page.push_str(&body[..at]);
89            page.push_str(&bar);
90            page.push_str(&body[at..]);
91
92            response = response.with_html(page);
93            response
94        })
95    }
96}
97
98/// Whether this response is a page a person is about to look at.
99///
100/// Deliberately narrow. A JSON API response with a debug bar spliced into it is
101/// no longer JSON, and the client parsing it gets a syntax error instead of
102/// data — which is a far worse afternoon than having no bar.
103fn is_a_page(response: &Response) -> bool {
104    if !response.status.is_success() && response.status != rustlavel_http::Status::NOT_FOUND {
105        return false;
106    }
107    response
108        .headers
109        .get("content-type")
110        .is_some_and(|value| value.to_ascii_lowercase().starts_with("text/html"))
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use rustlavel_http::{Router, Status, TestClient};
117
118    fn app() -> TestClient {
119        Collector::install();
120        let mut router = Router::new();
121        router.middleware(Inject);
122
123        router.get("/page", |_req: Request| async move {
124            Response::html("<html><body><h1>Hi</h1></body></html>")
125        });
126        router.get("/api", |_req: Request| async move {
127            Response::json(rustlavel_core::Json::object([("ok", true.into())]))
128        });
129        router.get("/fragment", |_req: Request| async move {
130            Response::html("<li>a row with no body tag</li>")
131        });
132        router.get("/download", |_req: Request| async move {
133            Response::text("id,name\n1,Ada").with_header("content-type", "text/csv")
134        });
135        router.get("/redirect", |_req: Request| async move { Response::see_other("/page") });
136
137        TestClient::new(router)
138    }
139
140    #[tokio::test]
141    async fn a_page_gets_the_bar_just_before_the_closing_body_tag() {
142        let body = app().get("/page").await.assert_ok().body();
143
144        assert!(body.contains("rl-bar"), "the bar should be on the page");
145        let bar_at = body.find("rl-bar").unwrap();
146        let close_at = body.rfind("</body>").unwrap();
147        assert!(bar_at < close_at, "the bar goes inside the body, not after it");
148        assert!(body.contains("<h1>Hi</h1>"), "the page itself survives");
149    }
150
151    #[tokio::test]
152    async fn json_is_left_alone_because_a_bar_would_stop_it_parsing() {
153        let response = app().get("/api").await.assert_ok();
154        let body = response.body();
155
156        assert!(!body.contains("rl-bar"), "a bar in JSON breaks every client of it");
157        rustlavel_core::Json::parse(&body).expect("the response is still valid JSON");
158    }
159
160    #[tokio::test]
161    async fn a_fragment_with_no_body_tag_is_left_alone() {
162        // A partial returned to fetch() has no </body>. Appending the bar would
163        // drop a fixed-position overlay inside whatever element received it.
164        let body = app().get("/fragment").await.assert_ok().body();
165
166        assert!(!body.contains("rl-bar"));
167        assert_eq!(body, "<li>a row with no body tag</li>");
168    }
169
170    #[tokio::test]
171    async fn a_download_is_left_alone() {
172        let body = app().get("/download").await.assert_ok().body();
173
174        assert!(!body.contains("rl-bar"), "a CSV with HTML in it is a corrupt CSV");
175        assert_eq!(body, "id,name\n1,Ada");
176    }
177
178    #[tokio::test]
179    async fn a_redirect_is_left_alone() {
180        let response = app().get("/redirect").await.assert_status(303);
181        assert!(!response.body().contains("rl-bar"));
182    }
183
184    #[test]
185    fn it_is_off_in_production_unless_somebody_says_otherwise_in_full() {
186        let production = Config::with_defaults();
187        production.set("app.env", rustlavel_core::Json::from("production"));
188
189        assert!(!DebugBar::new().wanted(&production), "off by default in production");
190        assert!(DebugBar::new().even_in_production().wanted(&production));
191    }
192
193    #[test]
194    fn it_is_on_in_development_without_being_asked() {
195        let config = Config::with_defaults();
196        assert!(DebugBar::new().wanted(&config));
197    }
198
199    #[test]
200    fn config_can_turn_it_off_even_in_development() {
201        let config = Config::with_defaults();
202        config.set("debugbar.enabled", rustlavel_core::Json::Bool(false));
203        assert!(!DebugBar::new().wanted(&config));
204    }
205
206    #[tokio::test]
207    async fn a_404_page_still_gets_a_bar() {
208        // The request you most want to inspect is often the one that failed.
209        let mut router = Router::new();
210        router.middleware(Inject);
211        router.fallback(|_req: Request| async move {
212            Response::html("<html><body>Not found</body></html>").with_status(Status::NOT_FOUND)
213        });
214
215        let body = TestClient::new(router).get("/nowhere").await.assert_status(404).body();
216        assert!(body.contains("rl-bar"));
217    }
218}