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}