Skip to main content

larust_http/
locale.rs

1//! Locale negotiation middleware - the session-reading half of
2//! `larust_lang`'s own current-locale story (see that crate's doc comment
3//! for the full design: translation files, `t`/`t_with`, the task-local
4//! scope this wraps). Opt-in, like `require_auth`/`csrf::verify` - unlike
5//! `push_registry_scope`, `larust_lang::current_locale()` already degrades
6//! correctly with no scope established at all (falls back to
7//! `Config::app_locale`), so there's no "always on, unconditional" case to
8//! make for this the way there was for `@push`/`@stack`.
9
10use crate::session::Session;
11use axum::extract::Request;
12use axum::middleware::Next;
13use axum::response::Response;
14
15/// The session key `LanguageController`-style app code should
16/// `session.insert("locale", ...)` into - matches whatever a real app's
17/// own language-switcher route already sets, so wiring this middleware in
18/// doesn't require renaming an existing session key.
19pub const SESSION_KEY: &str = "locale";
20
21/// Establishes a fresh `larust_lang` locale scope for this request, then -
22/// if the session already has a stored `"locale"` value - overrides it for
23/// the rest of the request. Add after `.with_sessions(...)`, the same
24/// ordering constraint `csrf::verify`/`require_auth` already have:
25///
26/// ```ignore
27/// router
28///     .with_sessions(pool, secure)
29///     .await?
30///     .middleware(axum::middleware::from_fn(larust_http::locale::negotiate))
31/// ```
32///
33/// Deliberately doesn't validate the stored value against any "supported
34/// locales" list - that's app-owned data (which `resources/lang/*.json`
35/// files actually exist), not something this middleware can know; an
36/// unrecognized locale simply means every `t()`/`t_with()` call falls
37/// through to `Config::app_fallback_locale`, the same as a missing key
38/// would.
39pub async fn negotiate(session: Session, request: Request, next: Next) -> Response {
40    larust_lang::with_locale_scope(async move {
41        if let Ok(Some(locale)) = session.get::<String>(SESSION_KEY).await {
42            larust_lang::set_current_locale(locale);
43        }
44        next.run(request).await
45    })
46    .await
47}
48
49/// [`negotiate`]'s session-free sibling, for routers with no cookie session
50/// at all - a bearer-token API (`routes/api.rs`'s own shape) has nowhere to
51/// persist a per-user locale *choice*, but a caller can still say what they
52/// want on each request via the standard `Accept-Language` header, so this
53/// reads that instead of a session key. Same task-local scope, same
54/// "degrade to `Config::app_locale` if unrecognized" fallback as
55/// [`negotiate`] - just a different source for the raw locale string.
56///
57/// ```ignore
58/// router.middleware(axum::middleware::from_fn(
59///     larust_http::locale::negotiate_from_header,
60/// ))
61/// ```
62pub async fn negotiate_from_header(request: Request, next: Next) -> Response {
63    let header = request
64        .headers()
65        .get(axum::http::header::ACCEPT_LANGUAGE)
66        .and_then(|value| value.to_str().ok())
67        .map(str::to_string);
68    larust_lang::with_locale_scope(async move {
69        if let Some(locale) = header.as_deref().and_then(preferred_language) {
70            larust_lang::set_current_locale(locale);
71        }
72        next.run(request).await
73    })
74    .await
75}
76
77/// Picks the caller's single most-preferred language out of an
78/// `Accept-Language` header (e.g. `"es-ES,es;q=0.9,en;q=0.8"` → `"es"`),
79/// honoring `q=` quality values and preferring the first-listed tag among
80/// ties (matching how browsers already order the header themselves).
81/// Deliberately not a full RFC 4647 implementation - no wildcard/range
82/// matching, no fallback chain of multiple acceptable locales - just enough
83/// to pick one primary language subtag, the same "don't validate against a
84/// supported-locales list" stance [`negotiate`] already takes: an
85/// unrecognized result simply falls through to `t()`'s own existing
86/// fallback behavior.
87fn preferred_language(header: &str) -> Option<String> {
88    let mut best: Option<(String, f32)> = None;
89    for entry in header.split(',') {
90        let mut segments = entry.trim().split(';');
91        let tag = segments.next()?.trim();
92        if tag.is_empty() {
93            continue;
94        }
95        let mut quality = 1.0f32;
96        let mut has_invalid_quality = false;
97        for segment in segments {
98            if let Some(raw_quality) = segment.trim().strip_prefix("q=") {
99                match raw_quality.parse::<f32>() {
100                    Ok(value) => quality = value,
101                    Err(_) => has_invalid_quality = true,
102                }
103            }
104        }
105        if has_invalid_quality {
106            continue;
107        }
108        let primary = tag.split('-').next().unwrap_or(tag);
109        if primary.is_empty() || primary == "*" {
110            continue;
111        }
112        let is_better = match &best {
113            Some((_, best_quality)) => quality > *best_quality,
114            None => true,
115        };
116        if is_better {
117            best = Some((primary.to_lowercase(), quality));
118        }
119    }
120    best.map(|(locale, _)| locale)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::preferred_language;
126
127    #[test]
128    fn picks_the_only_tag_present() {
129        assert_eq!(preferred_language("es"), Some("es".to_string()));
130    }
131
132    #[test]
133    fn strips_the_region_subtag() {
134        assert_eq!(preferred_language("es-ES"), Some("es".to_string()));
135    }
136
137    #[test]
138    fn honors_quality_values_over_list_order() {
139        assert_eq!(
140            preferred_language("en;q=0.8,es;q=0.9"),
141            Some("es".to_string())
142        );
143    }
144
145    #[test]
146    fn prefers_the_first_listed_tag_among_equal_quality() {
147        assert_eq!(
148            preferred_language("en;q=0.9,es;q=0.9"),
149            Some("en".to_string())
150        );
151    }
152
153    #[test]
154    fn defaults_missing_quality_to_one() {
155        assert_eq!(preferred_language("en;q=0.5,fr"), Some("fr".to_string()));
156    }
157
158    #[test]
159    fn skips_the_wildcard_tag() {
160        assert_eq!(preferred_language("*"), None);
161        assert_eq!(preferred_language("*,es;q=0.5"), Some("es".to_string()));
162    }
163
164    #[test]
165    fn degrades_to_none_on_an_empty_header() {
166        assert_eq!(preferred_language(""), None);
167    }
168
169    #[test]
170    fn ignores_unparseable_quality_values() {
171        assert_eq!(
172            preferred_language("en;q=not-a-number,fr;q=0.5"),
173            Some("fr".to_string())
174        );
175    }
176}