rustlavel_debugbar/
plugin.rs1use 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#[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 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
64struct 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 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
98fn 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 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 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}