Skip to main content

modo/i18n/
locale.rs

1use http::request::Parts;
2use std::sync::Arc;
3
4use super::config::I18nConfig;
5
6/// Trait for extracting the active locale from a request.
7///
8/// Implementations are tried in order within the locale chain built by the
9/// i18n module. The first resolver that returns `Some` wins; if all resolvers
10/// return `None`, [`I18nConfig::default_locale`] is used.
11///
12/// # Empty-allowlist semantics
13///
14/// Built-in resolvers disagree on what an empty `available_locales` slice
15/// means, so pick the constructor inputs deliberately:
16///
17/// - [`QueryParamResolver`] and [`CookieResolver`] treat an empty allowlist as
18///   "accept any value" — whatever the caller supplied is returned verbatim.
19/// - [`AcceptLanguageResolver`] treats an empty allowlist as "match nothing"
20///   because it can only return locales that appear in the list.
21///
22/// The default chain built by [`I18n::new`](super::I18n::new) hands every
23/// resolver the same `available_locales`, so the asymmetry only surfaces when
24/// wiring resolvers manually.
25pub trait LocaleResolver: Send + Sync {
26    /// Returns a locale string (e.g. `"en"`, `"uk"`) if this resolver can determine
27    /// the locale from the request, or `None` to fall through to the next resolver.
28    fn resolve(&self, parts: &Parts) -> Option<String>;
29}
30
31// --- QueryParamResolver ---
32
33/// Resolves the active locale from a URL query parameter.
34///
35/// When `available_locales` is non-empty, only values present in that list are
36/// accepted. An empty slice means "accept any value" — the resolver returns
37/// whatever string the request carried. See [`LocaleResolver`] for how this
38/// differs from [`AcceptLanguageResolver`].
39pub struct QueryParamResolver {
40    param_name: String,
41    available_locales: Vec<String>,
42}
43
44impl QueryParamResolver {
45    /// Creates a new resolver that looks at `param_name` in the query string.
46    ///
47    /// `available_locales` constrains which values are accepted; pass `&[]` to accept
48    /// all values.
49    pub fn new(param_name: &str, available_locales: &[String]) -> Self {
50        Self {
51            param_name: param_name.to_string(),
52            available_locales: available_locales.to_vec(),
53        }
54    }
55}
56
57impl LocaleResolver for QueryParamResolver {
58    fn resolve(&self, parts: &Parts) -> Option<String> {
59        let query = parts.uri.query()?;
60        for pair in query.split('&') {
61            if let Some((key, value)) = pair.split_once('=')
62                && key == self.param_name
63                && (self.available_locales.is_empty()
64                    || self.available_locales.iter().any(|l| l == value))
65            {
66                return Some(value.to_string());
67            }
68        }
69        None
70    }
71}
72
73// --- CookieResolver ---
74
75/// Resolves the active locale from a cookie.
76///
77/// When `available_locales` is non-empty, only values present in that list are
78/// accepted. An empty slice means "accept any value" — the resolver returns
79/// whatever string the cookie carried. See [`LocaleResolver`] for how this
80/// differs from [`AcceptLanguageResolver`].
81pub struct CookieResolver {
82    cookie_name: String,
83    available_locales: Vec<String>,
84}
85
86impl CookieResolver {
87    /// Creates a new resolver that reads `cookie_name`.
88    ///
89    /// `available_locales` constrains which values are accepted; pass `&[]` to accept
90    /// all values.
91    pub fn new(cookie_name: &str, available_locales: &[String]) -> Self {
92        Self {
93            cookie_name: cookie_name.to_string(),
94            available_locales: available_locales.to_vec(),
95        }
96    }
97}
98
99impl LocaleResolver for CookieResolver {
100    fn resolve(&self, parts: &Parts) -> Option<String> {
101        let cookie_header = parts.headers.get("cookie")?.to_str().ok()?;
102        for cookie in cookie_header.split(';') {
103            let cookie = cookie.trim();
104            if let Some((name, value)) = cookie.split_once('=')
105                && name.trim() == self.cookie_name
106            {
107                let value = value.trim();
108                if self.available_locales.is_empty()
109                    || self.available_locales.iter().any(|l| l == value)
110                {
111                    return Some(value.to_string());
112                }
113            }
114        }
115        None
116    }
117}
118
119// --- SessionResolver ---
120
121/// Resolves the active locale from the session data.
122///
123/// Reads the `"locale"` key from the session's JSON data. Requires
124/// [`SessionLayer`](crate::auth::session::SessionLayer) to be installed before this resolver
125/// runs in the middleware stack.
126pub struct SessionResolver;
127
128impl LocaleResolver for SessionResolver {
129    fn resolve(&self, parts: &Parts) -> Option<String> {
130        let state = parts
131            .extensions
132            .get::<Arc<crate::auth::session::SessionState>>()?;
133        let guard = state.current.lock().ok()?;
134        let session = guard.as_ref()?;
135        if let serde_json::Value::Object(ref map) = session.data
136            && let Some(serde_json::Value::String(locale)) = map.get("locale")
137        {
138            return Some(locale.clone());
139        }
140        None
141    }
142}
143
144// --- AcceptLanguageResolver ---
145
146/// Resolves the active locale from the `Accept-Language` HTTP header.
147///
148/// Parses quality values (`q=`), strips region subtags (`en-US` → `en`), and
149/// picks the highest-quality language that matches `available`. Unlike
150/// [`QueryParamResolver`] and [`CookieResolver`], an empty `available` list
151/// means "match nothing" — this resolver can only return values that appear
152/// in the list. See [`LocaleResolver`] for the full comparison.
153pub struct AcceptLanguageResolver {
154    available: Vec<String>,
155}
156
157impl AcceptLanguageResolver {
158    /// Creates a new resolver that accepts only locales in `available`.
159    pub fn new(available: &[&str]) -> Self {
160        Self {
161            available: available.iter().map(|s| s.to_string()).collect(),
162        }
163    }
164}
165
166impl LocaleResolver for AcceptLanguageResolver {
167    fn resolve(&self, parts: &Parts) -> Option<String> {
168        let header = parts.headers.get("accept-language")?.to_str().ok()?;
169
170        // Parse "en;q=0.9, uk;q=0.8" → sorted by quality
171        let mut langs: Vec<(String, f32)> = header
172            .split(',')
173            .map(|entry| {
174                let entry = entry.trim();
175                let (lang, quality) = if let Some((l, q)) = entry.split_once(";q=") {
176                    (l.trim().to_string(), q.trim().parse::<f32>().unwrap_or(0.0))
177                } else {
178                    (entry.to_string(), 1.0)
179                };
180                // Normalize: strip region tag ("en-US" → "en")
181                let lang = lang.split('-').next().unwrap_or(&lang).to_lowercase();
182                (lang, quality)
183            })
184            .collect();
185
186        // Sort by quality descending
187        langs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
188
189        // Find first match in available locales
190        for (lang, _) in &langs {
191            if self.available.iter().any(|a| a == lang) {
192                return Some(lang.clone());
193            }
194        }
195
196        None
197    }
198}
199
200// --- Chain helpers ---
201
202pub(super) fn default_chain(
203    config: &I18nConfig,
204    available_locales: &[String],
205) -> Vec<Arc<dyn LocaleResolver>> {
206    let mut chain: Vec<Arc<dyn LocaleResolver>> = vec![
207        Arc::new(QueryParamResolver::new(
208            &config.locale_query_param,
209            available_locales,
210        )),
211        Arc::new(CookieResolver::new(
212            &config.locale_cookie,
213            available_locales,
214        )),
215    ];
216    chain.push(Arc::new(SessionResolver));
217    chain.push(Arc::new(AcceptLanguageResolver::new(
218        &available_locales
219            .iter()
220            .map(|s| s.as_str())
221            .collect::<Vec<_>>(),
222    )));
223    chain
224}
225
226pub(super) fn resolve_locale(chain: &[Arc<dyn LocaleResolver>], parts: &Parts) -> Option<String> {
227    chain.iter().find_map(|r| r.resolve(parts))
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use http::Request;
234
235    fn parts_from_request(req: Request<()>) -> http::request::Parts {
236        req.into_parts().0
237    }
238
239    #[test]
240    fn query_param_resolver_extracts_lang() {
241        let resolver = QueryParamResolver::new("lang", &[]);
242        let req = Request::builder().uri("/?lang=uk").body(()).unwrap();
243        let parts = parts_from_request(req);
244        assert_eq!(resolver.resolve(&parts), Some("uk".into()));
245    }
246
247    #[test]
248    fn query_param_resolver_returns_none_when_absent() {
249        let resolver = QueryParamResolver::new("lang", &[]);
250        let req = Request::builder().uri("/").body(()).unwrap();
251        let parts = parts_from_request(req);
252        assert_eq!(resolver.resolve(&parts), None);
253    }
254
255    #[test]
256    fn cookie_resolver_extracts_locale() {
257        let resolver = CookieResolver::new("lang", &[]);
258        let req = Request::builder()
259            .header("cookie", "lang=uk; other=value")
260            .body(())
261            .unwrap();
262        let parts = parts_from_request(req);
263        assert_eq!(resolver.resolve(&parts), Some("uk".into()));
264    }
265
266    #[test]
267    fn cookie_resolver_returns_none_when_absent() {
268        let resolver = CookieResolver::new("lang", &[]);
269        let req = Request::builder().body(()).unwrap();
270        let parts = parts_from_request(req);
271        assert_eq!(resolver.resolve(&parts), None);
272    }
273
274    #[test]
275    fn accept_language_resolver_picks_best_match() {
276        let resolver = AcceptLanguageResolver::new(&["en", "uk", "fr"]);
277        let req = Request::builder()
278            .header("accept-language", "uk;q=0.9, en;q=0.8, fr;q=0.7")
279            .body(())
280            .unwrap();
281        let parts = parts_from_request(req);
282        assert_eq!(resolver.resolve(&parts), Some("uk".into()));
283    }
284
285    #[test]
286    fn accept_language_resolver_ignores_unsupported() {
287        let resolver = AcceptLanguageResolver::new(&["en"]);
288        let req = Request::builder()
289            .header("accept-language", "de;q=0.9, en;q=0.8")
290            .body(())
291            .unwrap();
292        let parts = parts_from_request(req);
293        assert_eq!(resolver.resolve(&parts), Some("en".into()));
294    }
295
296    #[test]
297    fn accept_language_resolver_returns_none_for_no_match() {
298        let resolver = AcceptLanguageResolver::new(&["en"]);
299        let req = Request::builder()
300            .header("accept-language", "de, fr")
301            .body(())
302            .unwrap();
303        let parts = parts_from_request(req);
304        assert_eq!(resolver.resolve(&parts), None);
305    }
306
307    #[test]
308    fn accept_language_normalizes_region_tags() {
309        let resolver = AcceptLanguageResolver::new(&["en"]);
310        let req = Request::builder()
311            .header("accept-language", "en-US;q=0.9")
312            .body(())
313            .unwrap();
314        let parts = parts_from_request(req);
315        assert_eq!(resolver.resolve(&parts), Some("en".into()));
316    }
317
318    #[test]
319    fn session_resolver_returns_none_without_session() {
320        let resolver = SessionResolver;
321        let req = Request::builder().body(()).unwrap();
322        let parts = parts_from_request(req);
323        assert_eq!(resolver.resolve(&parts), None);
324    }
325
326    #[test]
327    fn query_param_rejects_unknown_locale() {
328        let available = vec!["en".into(), "uk".into()];
329        let resolver = QueryParamResolver::new("lang", &available);
330        let req = Request::builder().uri("/?lang=xx").body(()).unwrap();
331        let parts = parts_from_request(req);
332        assert_eq!(resolver.resolve(&parts), None);
333    }
334
335    #[test]
336    fn query_param_accepts_known_locale() {
337        let available = vec!["en".into(), "uk".into()];
338        let resolver = QueryParamResolver::new("lang", &available);
339        let req = Request::builder().uri("/?lang=uk").body(()).unwrap();
340        let parts = parts_from_request(req);
341        assert_eq!(resolver.resolve(&parts), Some("uk".into()));
342    }
343
344    #[test]
345    fn cookie_rejects_unknown_locale() {
346        let available = vec!["en".into(), "uk".into()];
347        let resolver = CookieResolver::new("lang", &available);
348        let req = Request::builder()
349            .header("cookie", "lang=xx")
350            .body(())
351            .unwrap();
352        let parts = parts_from_request(req);
353        assert_eq!(resolver.resolve(&parts), None);
354    }
355
356    #[test]
357    fn cookie_accepts_known_locale() {
358        let available = vec!["en".into(), "uk".into()];
359        let resolver = CookieResolver::new("lang", &available);
360        let req = Request::builder()
361            .header("cookie", "lang=uk")
362            .body(())
363            .unwrap();
364        let parts = parts_from_request(req);
365        assert_eq!(resolver.resolve(&parts), Some("uk".into()));
366    }
367
368    #[test]
369    fn resolve_locale_chain_ordering() {
370        let available: Vec<String> = vec!["en".into(), "uk".into(), "fr".into()];
371        let chain: Vec<Arc<dyn LocaleResolver>> = vec![
372            Arc::new(QueryParamResolver::new("lang", &available)),
373            Arc::new(CookieResolver::new("lang", &available)),
374        ];
375        // Both query param and cookie set — query param should win (first in chain)
376        let req = Request::builder()
377            .uri("/?lang=uk")
378            .header("cookie", "lang=fr")
379            .body(())
380            .unwrap();
381        let parts = parts_from_request(req);
382        let result = resolve_locale(&chain, &parts);
383        assert_eq!(result, Some("uk".into()));
384    }
385
386    #[test]
387    fn default_chain_builds_all_resolvers() {
388        let config = I18nConfig::default();
389        let available = vec!["en".into(), "uk".into()];
390        let chain = default_chain(&config, &available);
391        assert_eq!(chain.len(), 4);
392    }
393}