1use sova_core::Request;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum LocaleSource {
8 Path,
9 Query,
10 Cookie,
11 AcceptLanguage,
12 Default,
13}
14
15#[derive(Debug, Clone)]
16pub struct ResolvedLocale {
17 pub code: String,
18 pub source: LocaleSource,
19}
20
21#[derive(Debug, Clone)]
22pub struct ResolveOptions {
23 pub locales: Vec<String>,
24 pub default: String,
25 pub query_key: String,
26 pub cookie_name: Option<String>,
27 pub path_prefix: bool,
29}
30
31impl ResolveOptions {
32 pub fn new(locales: impl IntoIterator<Item = impl Into<String>>, default: impl Into<String>) -> Self {
33 Self {
34 locales: locales.into_iter().map(|s| s.into()).collect(),
35 default: default.into(),
36 query_key: "locale".into(),
37 cookie_name: None,
38 path_prefix: true,
39 }
40 }
41}
42
43pub fn resolve_server_locale(req: &Request, opts: &ResolveOptions) -> ResolvedLocale {
44 if opts.path_prefix {
45 if let Some(code) = path_locale(&req.path, &opts.locales) {
46 return ResolvedLocale {
47 code,
48 source: LocaleSource::Path,
49 };
50 }
51 }
52
53 if let Some(code) = req
54 .query
55 .get(&opts.query_key)
56 .map(|s| s.as_str())
57 .and_then(|c| normalize_known(c, &opts.locales))
58 {
59 return ResolvedLocale {
60 code,
61 source: LocaleSource::Query,
62 };
63 }
64
65 #[cfg(feature = "cookie")]
66 if let Some(name) = &opts.cookie_name {
67 if let Some(code) = req
68 .get::<sova_cookies::Cookies>()
69 .and_then(|c| c.get(name))
70 .and_then(|c| normalize_known(c, &opts.locales))
71 {
72 return ResolvedLocale {
73 code,
74 source: LocaleSource::Cookie,
75 };
76 }
77 }
78
79 if let Some(raw) = req.header("accept-language") {
80 if let Some(code) = negotiate_accept_language(raw, &opts.locales) {
81 return ResolvedLocale {
82 code,
83 source: LocaleSource::AcceptLanguage,
84 };
85 }
86 }
87
88 ResolvedLocale {
89 code: opts.default.clone(),
90 source: LocaleSource::Default,
91 }
92}
93
94fn path_locale(path: &str, locales: &[String]) -> Option<String> {
95 let seg = path.trim_start_matches('/').split('/').next().unwrap_or("");
96 normalize_known(seg, locales)
97}
98
99fn normalize_known(code: &str, locales: &[String]) -> Option<String> {
100 let lower = code.to_ascii_lowercase();
101 locales
102 .iter()
103 .find(|l| l.eq_ignore_ascii_case(&lower))
104 .cloned()
105}
106
107pub fn negotiate_accept_language(header: &str, locales: &[String]) -> Option<String> {
109 let mut tags: Vec<(String, f32)> = Vec::new();
110 for part in header.split(',') {
111 let mut it = part.trim().split(';');
112 let tag = it.next()?.trim().to_ascii_lowercase();
113 if tag.is_empty() || tag == "*" {
114 continue;
115 }
116 let mut q = 1.0_f32;
117 for param in it {
118 let param = param.trim();
119 if let Some(v) = param.strip_prefix("q=") {
120 q = v.parse().unwrap_or(0.0);
121 }
122 }
123 tags.push((tag, q));
124 }
125 tags.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
126
127 for (tag, _) in &tags {
128 if let Some(c) = normalize_known(tag, locales) {
129 return Some(c);
130 }
131 if let Some((lang, _)) = tag.split_once('-') {
132 if let Some(c) = normalize_known(lang, locales) {
133 return Some(c);
134 }
135 }
136 }
137 None
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use http::Method;
144 use sova_core::Request;
145
146 fn opts() -> ResolveOptions {
147 ResolveOptions::new(["en", "de"], "en")
148 }
149
150 #[test]
151 fn path_beats_query() {
152 let req = Request::builder()
153 .method(Method::GET)
154 .path("/de/x")
155 .build();
156 let mut req = req;
158 req.query.insert("locale".into(), "en".into());
159 let r = resolve_server_locale(&req, &opts());
160 assert_eq!(r.code, "de");
161 assert_eq!(r.source, LocaleSource::Path);
162 }
163
164 #[test]
165 fn query_beats_accept() {
166 let req = Request::builder()
167 .method(Method::GET)
168 .path("/blog")
169 .header("accept-language", "de")
170 .build();
171 let mut req = req;
172 req.query.insert("locale".into(), "en".into());
173 let mut o = opts();
174 o.path_prefix = false;
175 let r = resolve_server_locale(&req, &o);
176 assert_eq!(r.code, "en");
177 assert_eq!(r.source, LocaleSource::Query);
178 }
179
180 #[test]
181 fn accept_language_negotiation() {
182 assert_eq!(
183 negotiate_accept_language("fr-CH, fr;q=0.9, de;q=0.8, en;q=0.7", &["en".into(), "de".into()]),
184 Some("de".into())
185 );
186 }
187}