Skip to main content

sova_core/
middleware.rs

1use crate::handler::{BoxFuture, Handler};
2use crate::request::Request;
3use crate::response::Response;
4use std::future::Future;
5use std::sync::Arc;
6
7/// Call the rest of the middleware / handler chain.
8pub type Next = Box<dyn FnOnce(Request) -> BoxFuture<Response> + Send>;
9
10/// Type-erased middleware: `(Request, Next) -> Response`.
11pub type Middleware = Arc<dyn Fn(Request, Next) -> BoxFuture<Response> + Send + Sync>;
12
13/// Named middleware entry used for [`crate::Router::explain`].
14#[derive(Clone)]
15pub struct MwEntry {
16    pub name: String,
17    pub mw: Middleware,
18}
19
20/// Wrap middleware with an explicit explain label (e.g. `named("auth", …)`).
21pub fn named(name: impl Into<String>, mw: impl IntoMiddleware) -> MwEntry {
22    MwEntry {
23        name: name.into(),
24        mw: mw.into_middleware(),
25    }
26}
27
28pub(crate) fn abbreviate_type_name(full: &str) -> String {
29    let trimmed = full
30        .trim_end_matches(">::{{closure}}")
31        .trim_end_matches("::{{closure}}")
32        .trim_end_matches("{{closure}}");
33    let base = trimmed.rsplit("::").next().unwrap_or(trimmed);
34    if base.is_empty() || base == "{{closure}}" {
35        "closure".into()
36    } else {
37        base.to_string()
38    }
39}
40
41pub trait IntoMiddleware {
42    fn into_middleware(self) -> Middleware;
43}
44
45/// Convert into a named [`MwEntry`] (explicit name via [`named`], else type name).
46pub trait IntoMwEntry {
47    fn into_mw_entry(self) -> MwEntry;
48}
49
50impl<F, Fut> IntoMiddleware for F
51where
52    F: Fn(Request, Next) -> Fut + Send + Sync + 'static,
53    Fut: Future<Output = Response> + Send + 'static,
54{
55    fn into_middleware(self) -> Middleware {
56        Arc::new(move |req, next| Box::pin(self(req, next)))
57    }
58}
59
60impl IntoMiddleware for Middleware {
61    fn into_middleware(self) -> Middleware {
62        self
63    }
64}
65
66impl IntoMwEntry for MwEntry {
67    fn into_mw_entry(self) -> MwEntry {
68        self
69    }
70}
71
72impl IntoMwEntry for Middleware {
73    fn into_mw_entry(self) -> MwEntry {
74        MwEntry {
75            name: "mw".into(),
76            mw: self,
77        }
78    }
79}
80
81impl<F, Fut> IntoMwEntry for F
82where
83    F: Fn(Request, Next) -> Fut + Send + Sync + 'static,
84    Fut: Future<Output = Response> + Send + 'static,
85{
86    fn into_mw_entry(self) -> MwEntry {
87        MwEntry {
88            name: abbreviate_type_name(std::any::type_name::<F>()),
89            mw: self.into_middleware(),
90        }
91    }
92}
93
94/// Middleware with owned state — hides the `Arc::clone` dance.
95///
96/// Prefer this over capturing many fields and cloning them into each future.
97/// If a closure needs more than one `.clone()`, the state should be one `Arc`
98/// (this helper) or process-lifetime `&'static` via [`with_leaked`].
99pub fn with_state<S, F, Fut>(state: S, f: F) -> Middleware
100where
101    S: Send + Sync + 'static,
102    F: Fn(Arc<S>, Request, Next) -> Fut + Send + Sync + 'static,
103    Fut: Future<Output = Response> + Send + 'static,
104{
105    let state = Arc::new(state);
106    Arc::new(move |req, next| Box::pin(f(Arc::clone(&state), req, next)))
107}
108
109/// Immutable plugin config that lives for the process.
110///
111/// # ponytail
112/// One-shot `Box::leak` at install — no Arc atomics on the hot path.
113pub fn with_leaked<S, F, Fut>(state: S, f: F) -> Middleware
114where
115    S: Send + Sync + 'static,
116    F: Fn(&'static S, Request, Next) -> Fut + Send + Sync + 'static,
117    Fut: Future<Output = Response> + Send + 'static,
118{
119    let state: &'static S = Box::leak(Box::new(state));
120    Arc::new(move |req, next| Box::pin(f(state, req, next)))
121}
122
123/// Run `f` on the request before the rest of the chain.
124pub fn before<F, Fut>(name: impl Into<String>, f: F) -> MwEntry
125where
126    F: Fn(Request) -> Fut + Send + Sync + 'static,
127    Fut: Future<Output = Request> + Send + 'static,
128{
129    let f = Arc::new(f);
130    named(name, move |req, next: Next| {
131        let f = Arc::clone(&f);
132        async move {
133            let req = f(req).await;
134            next(req).await
135        }
136    })
137}
138
139/// Run `f` on the response after the rest of the chain.
140pub fn after<F, Fut>(name: impl Into<String>, f: F) -> MwEntry
141where
142    F: Fn(Response) -> Fut + Send + Sync + 'static,
143    Fut: Future<Output = Response> + Send + 'static,
144{
145    let f = Arc::new(f);
146    named(name, move |req, next: Next| {
147        let f = Arc::clone(&f);
148        async move {
149            let res = next(req).await;
150            f(res).await
151        }
152    })
153}
154
155/// `before` then chain then `after` under one explain name.
156pub fn around<B, BF, A, AF>(name: impl Into<String>, before_fn: B, after_fn: A) -> MwEntry
157where
158    B: Fn(Request) -> BF + Send + Sync + 'static,
159    BF: Future<Output = Request> + Send + 'static,
160    A: Fn(Response) -> AF + Send + Sync + 'static,
161    AF: Future<Output = Response> + Send + 'static,
162{
163    let before_fn = Arc::new(before_fn);
164    let after_fn = Arc::new(after_fn);
165    named(name, move |req, next: Next| {
166        let before_fn = Arc::clone(&before_fn);
167        let after_fn = Arc::clone(&after_fn);
168        async move {
169            let req = before_fn(req).await;
170            let res = next(req).await;
171            after_fn(res).await
172        }
173    })
174}
175
176/// After the handler: map buffered `text/html` bodies with `transform`.
177///
178/// Non-HTML / streamed responses are unchanged.
179pub fn map_html<F>(name: impl Into<String>, transform: F) -> MwEntry
180where
181    F: Fn(&str) -> Option<String> + Send + Sync + 'static,
182{
183    let transform = Arc::new(transform);
184    after(name, move |mut res| {
185        let transform = Arc::clone(&transform);
186        async move {
187            res.map_buffered_html(|html| transform(html));
188            res
189        }
190    })
191}
192
193/// Build Express-style onion once; returns a reusable [`Handler`].
194pub fn build_chain(middleware: &[Middleware], handler: Handler) -> Handler {
195    let mut next = handler;
196
197    for mw in middleware.iter().rev() {
198        let mw = Arc::clone(mw);
199        let inner = next;
200        next = Arc::new(move |req| {
201            let inner = Arc::clone(&inner);
202            let mw = Arc::clone(&mw);
203            mw(
204                req,
205                Box::new(move |r| {
206                    let inner = Arc::clone(&inner);
207                    inner(r)
208                }),
209            )
210        });
211    }
212
213    next
214}
215
216pub(crate) fn chain_from_entries(entries: &[MwEntry], handler: Handler) -> Handler {
217    let mws: Vec<Middleware> = entries.iter().map(|e| Arc::clone(&e.mw)).collect();
218    build_chain(&mws, handler)
219}
220
221/// Paths registered via [`logger_skip_path`] / [`logger_skip_paths`] are not logged
222/// (useful for health checks and `/_devtools/*`).
223pub fn logger() -> MwEntry {
224    named("logger", |req: Request, next: Next| async move {
225        let method = req.method.as_str().to_string();
226        let path = req.path.clone();
227        let quiet = logger_should_skip(&path);
228        let user_agent = req
229            .header("user-agent")
230            .unwrap_or("-")
231            .to_string();
232        let peer = req
233            .get::<crate::server::ClientAddr>()
234            .map(|a| a.0.to_string())
235            .unwrap_or_else(|| "-".into());
236        let request_id = req
237            .get::<crate::request_id::RequestId>()
238            .map(|r| r.0.clone())
239            .unwrap_or_default();
240        let start = std::time::Instant::now();
241        let res = next(req).await;
242        if quiet {
243            return res;
244        }
245        let status = res.status_code().as_u16();
246        let latency_ms = start.elapsed().as_millis() as u64;
247        if request_id.is_empty() {
248            tracing::info!(
249                method = %method,
250                path = %path,
251                status,
252                latency_ms,
253                peer = %peer,
254                user_agent = %user_agent,
255                "request"
256            );
257        } else {
258            tracing::info!(
259                request_id = %request_id,
260                method = %method,
261                path = %path,
262                status,
263                latency_ms,
264                peer = %peer,
265                user_agent = %user_agent,
266                "request"
267            );
268        }
269        res
270    })
271}
272
273static LOGGER_SKIP_PREFIXES: std::sync::OnceLock<std::sync::Mutex<Vec<String>>> =
274    std::sync::OnceLock::new();
275
276fn logger_skip_list() -> &'static std::sync::Mutex<Vec<String>> {
277    LOGGER_SKIP_PREFIXES.get_or_init(|| {
278        // Common browser noise — apps can still log these by not matching exact paths.
279        std::sync::Mutex::new(vec!["/favicon.ico".into()])
280    })
281}
282
283/// Skip access-log lines for paths that equal or start with `prefix`
284/// (e.g. `"/_devtools"` matches `/_devtools/config`).
285pub fn logger_skip_path(prefix: impl Into<String>) {
286    let p = prefix.into();
287    if p.is_empty() {
288        return;
289    }
290    let mut g = logger_skip_list().lock().unwrap();
291    if !g.iter().any(|x| x == &p) {
292        g.push(p);
293    }
294}
295
296/// Register several skip prefixes (see [`logger_skip_path`]).
297pub fn logger_skip_paths(prefixes: impl IntoIterator<Item = impl Into<String>>) {
298    for p in prefixes {
299        logger_skip_path(p);
300    }
301}
302
303/// Whether [`logger`] (and quiet [`crate::request_id`] spans) should skip this path.
304pub fn logger_should_skip(path: &str) -> bool {
305    let g = logger_skip_list().lock().unwrap();
306    g.iter()
307        .any(|p| path == p.as_str() || path.starts_with(&format!("{p}/")))
308}
309
310#[cfg(any(test, feature = "testing"))]
311pub fn logger_clear_skip_paths() {
312    *logger_skip_list().lock().unwrap() = vec!["/favicon.ico".into()];
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use http::Method;
319
320    fn ok_handler() -> Handler {
321        Arc::new(|_req: Request| Box::pin(async { Response::text("ok") }))
322    }
323
324    #[tokio::test]
325    async fn onion_order() {
326        let leaf = ok_handler();
327        let outer = |req: Request, next: Next| async move {
328            let mut res = next(req).await;
329            res = res.header("x-outer", "1");
330            res
331        };
332        let inner = |req: Request, next: Next| async move {
333            let mut res = next(req).await;
334            res = res.header("x-inner", "1");
335            res
336        };
337        let chain = build_chain(
338            &[outer.into_middleware(), inner.into_middleware()],
339            leaf,
340        );
341        let res = chain(Request::new(Method::GET, "/")).await;
342        assert_eq!(res.body_bytes(), Some(b"ok".as_slice()));
343        assert_eq!(res.headers.get("x-outer").map(|v| v.to_str().unwrap()), Some("1"));
344        assert_eq!(res.headers.get("x-inner").map(|v| v.to_str().unwrap()), Some("1"));
345    }
346
347    #[tokio::test]
348    async fn with_state_runs() {
349        let leaf = ok_handler();
350        let mw = with_state(7u32, |n, req, next| async move {
351            assert_eq!(*n, 7);
352            next(req).await
353        });
354        let chain = build_chain(&[mw], leaf);
355        assert_eq!(
356            chain(Request::new(Method::GET, "/")).await.body_bytes(),
357            Some(b"ok".as_slice())
358        );
359    }
360
361    #[test]
362    fn logger_skip_favicon_by_default() {
363        logger_clear_skip_paths();
364        assert!(logger_should_skip("/favicon.ico"));
365        assert!(!logger_should_skip("/favicon"));
366        logger_clear_skip_paths();
367    }
368
369    #[test]
370    fn logger_skip_matches_prefixes() {
371        logger_clear_skip_paths();
372        logger_skip_path("/_devtools");
373        logger_skip_path("/healthz");
374        assert!(logger_should_skip("/_devtools"));
375        assert!(logger_should_skip("/_devtools/config"));
376        assert!(logger_should_skip("/_devtools/requests/dt-1"));
377        assert!(logger_should_skip("/healthz"));
378        assert!(!logger_should_skip("/api/users"));
379        assert!(!logger_should_skip("/health"));
380        logger_clear_skip_paths();
381    }
382
383    #[test]
384    fn abbreviate_closure() {
385        let n = abbreviate_type_name("foo::bar::{{closure}}");
386        assert_eq!(n, "bar");
387    }
388}