Skip to main content

ferro_rs/lang/
middleware.rs

1//! Locale detection middleware.
2
3use crate::config::Config;
4use crate::http::Response;
5use crate::middleware::{Middleware, Next};
6use crate::Request;
7use async_trait::async_trait;
8use ferro_lang::{normalize_locale, LangConfig};
9
10use super::{locale_scope, with_locale_scope};
11
12/// Middleware that detects the request locale and sets it in task-local context.
13///
14/// Detection priority:
15/// 1. `?locale=xx` query parameter (explicit override)
16/// 2. `Accept-Language` header (first language tag)
17/// 3. `LangConfig.locale` default
18///
19/// # Example
20///
21/// ```rust,ignore
22/// use ferro_rs::{global_middleware, LangMiddleware};
23///
24/// pub fn register() {
25///     global_middleware!(LangMiddleware);
26/// }
27/// ```
28pub struct LangMiddleware;
29
30#[async_trait]
31impl Middleware for LangMiddleware {
32    async fn handle(&self, request: Request, next: Next) -> Response {
33        let config = Config::get::<LangConfig>().unwrap_or_default();
34
35        let detected = detect_locale(&request, &config);
36
37        let ctx = locale_scope();
38        {
39            let mut guard = ctx.write().await;
40            *guard = Some(detected);
41        }
42
43        with_locale_scope(ctx, async { next(request).await }).await
44    }
45}
46
47/// Detect locale from request with priority: query param > Accept-Language > config default.
48fn detect_locale(request: &Request, config: &LangConfig) -> String {
49    // 1. Explicit query parameter override
50    if let Some(locale) = request.query("locale") {
51        if !locale.is_empty() {
52            return normalize_locale(&locale);
53        }
54    }
55
56    // 2. Accept-Language header (first language tag)
57    if let Some(accept) = request.header("accept-language") {
58        if let Some(locale) = parse_accept_language(accept) {
59            return locale;
60        }
61    }
62
63    // 3. Config default
64    normalize_locale(&config.locale)
65}
66
67/// Parse Accept-Language header and return the first (highest priority) language tag.
68///
69/// Takes the first entry before any comma, strips quality suffix (`;q=...`),
70/// and normalizes via `normalize_locale()`.
71///
72/// Example: `"en-US,en;q=0.9,fr;q=0.8"` returns `"en-us"`.
73fn parse_accept_language(header: &str) -> Option<String> {
74    let first = header.split(',').next()?.trim();
75    if first.is_empty() {
76        return None;
77    }
78    // Strip quality value suffix (e.g. ";q=0.9")
79    let lang = first.split(';').next()?.trim();
80    if lang.is_empty() || lang == "*" {
81        return None;
82    }
83    Some(normalize_locale(lang))
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn parse_accept_language_full_header() {
92        let result = parse_accept_language("en-US,en;q=0.9,fr;q=0.8");
93        assert_eq!(result, Some("en-us".to_string()));
94    }
95
96    #[test]
97    fn parse_accept_language_single_tag() {
98        let result = parse_accept_language("fr");
99        assert_eq!(result, Some("fr".to_string()));
100    }
101
102    #[test]
103    fn parse_accept_language_with_quality() {
104        let result = parse_accept_language("de-DE;q=0.8");
105        assert_eq!(result, Some("de-de".to_string()));
106    }
107
108    #[test]
109    fn parse_accept_language_normalizes_underscore() {
110        let result = parse_accept_language("pt_BR,en;q=0.5");
111        assert_eq!(result, Some("pt-br".to_string()));
112    }
113
114    #[test]
115    fn parse_accept_language_empty() {
116        let result = parse_accept_language("");
117        assert_eq!(result, None);
118    }
119
120    #[test]
121    fn parse_accept_language_wildcard() {
122        let result = parse_accept_language("*");
123        assert_eq!(result, None);
124    }
125
126    #[test]
127    fn parse_accept_language_trims_whitespace() {
128        let result = parse_accept_language("  es-MX , en;q=0.5 ");
129        assert_eq!(result, Some("es-mx".to_string()));
130    }
131}