forked from
smokesignal.events/smokesignal
i18n+filtering fork - fluent-templates v2
1use anyhow::Result;
2use axum::{
3 extract::{FromRef, FromRequestParts},
4 http::request::Parts,
5 response::Response,
6};
7use axum_extra::extract::{cookie::CookieJar, Cached};
8use std::{cmp::Ordering, str::FromStr};
9use tracing::{debug, instrument, trace};
10use unic_langid::LanguageIdentifier;
11
12use crate::http::{context::WebContext, middleware_auth::Auth};
13use crate::i18n::{errors::I18nError, normalize_language_identifier};
14
15pub const COOKIE_LANG: &str = "lang";
16
17// HTMX header constants for i18n enrichment
18pub const HTMX_CURRENT_LOCALE: &str = "HX-Current-Locale";
19pub const HTMX_SUPPORTED_LOCALES: &str = "HX-Supported-Locales";
20
21/// Represents a language from the Accept-Language header with its quality value
22#[derive(Clone, Debug)]
23struct AcceptedLanguage {
24 value: String, // The language tag string
25 quality: f32, // The quality value (q parameter), from 0.0 to 1.0
26}
27
28impl Eq for AcceptedLanguage {}
29
30impl PartialEq for AcceptedLanguage {
31 fn eq(&self, other: &Self) -> bool {
32 // Languages are equal if they have the same quality and tag
33 self.quality == other.quality && self.value.eq(&other.value)
34 }
35}
36
37impl PartialOrd for AcceptedLanguage {
38 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
39 Some(self.cmp(other))
40 }
41}
42
43impl Ord for AcceptedLanguage {
44 fn cmp(&self, other: &Self) -> Ordering {
45 // Compare by quality (higher is better)
46 self.quality
47 .partial_cmp(&other.quality)
48 .unwrap_or(Ordering::Equal)
49 }
50}
51
52impl FromStr for AcceptedLanguage {
53 type Err = I18nError;
54
55 #[instrument(level = "trace", err, ret)]
56 fn from_str(s: &str) -> Result<Self, Self::Err> {
57 let s = s.trim();
58
59 // Split into language tag and quality value
60 let parts: Vec<&str> = s.split(';').collect();
61
62 // Get language tag
63 let value = parts.first().ok_or(I18nError::InvalidLanguage)?.trim();
64
65 if value.is_empty() {
66 return Err(I18nError::InvalidLanguage);
67 }
68
69 // Parse quality value if present (default to 1.0)
70 let quality = if parts.len() > 1 {
71 parts[1]
72 .trim()
73 .strip_prefix("q=")
74 .and_then(|q| q.parse::<f32>().ok())
75 .unwrap_or(1.0)
76 } else {
77 1.0
78 };
79
80 // Clamp quality to valid range
81 let quality = quality.clamp(0.0, 1.0);
82
83 Ok(AcceptedLanguage {
84 value: value.to_string(),
85 quality,
86 })
87 }
88}
89
90/// Wrapper around LanguageIdentifier for the current request's language
91#[derive(Clone, Debug)]
92pub struct Language(pub LanguageIdentifier);
93
94impl std::fmt::Display for Language {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 write!(f, "{}", self.0)
97 }
98}
99
100impl std::ops::Deref for Language {
101 type Target = LanguageIdentifier;
102
103 fn deref(&self) -> &Self::Target {
104 &self.0
105 }
106}
107
108impl From<LanguageIdentifier> for Language {
109 fn from(id: LanguageIdentifier) -> Self {
110 Language(id)
111 }
112}
113
114impl<S> FromRequestParts<S> for Language
115where
116 WebContext: FromRef<S>,
117 S: Send + Sync,
118{
119 type Rejection = Response;
120
121 async fn from_request_parts(parts: &mut Parts, context: &S) -> Result<Self, Self::Rejection> {
122 trace!("Extracting Language from request");
123 let web_context = WebContext::from_ref(context);
124 let auth: Auth = Cached::<Auth>::from_request_parts(parts, context).await?.0;
125
126 // 1. Try to get language from user's profile settings
127 if let Some(handle) = &auth.0 {
128 if let Ok(auth_lang) = handle.language.parse::<LanguageIdentifier>() {
129 let normalized_lang = normalize_language_identifier(&auth_lang);
130 debug!(language = %normalized_lang, "Using language from user profile");
131 return Ok(Self(normalized_lang));
132 }
133 }
134
135 // 2. Try to get language from cookies (optimized)
136 let cookie_jar = CookieJar::from_headers(&parts.headers);
137 if let Some(lang_cookie) = cookie_jar.get(COOKIE_LANG) {
138 trace!(cookie_value = %lang_cookie.value(), "Found language cookie");
139
140 if let Some(lang) = validate_cookie_language(
141 lang_cookie.value(),
142 &web_context.i18n_context.supported_languages
143 ) {
144 let normalized_lang = normalize_language_identifier(&lang);
145 debug!(language = %normalized_lang, "Using language from cookie");
146 return Ok(Self(normalized_lang));
147 }
148 }
149
150 // 3. Try to get language from Accept-Language header (optimized)
151 if let Some(header) = parts.headers.get("accept-language") {
152 if let Ok(header_str) = header.to_str() {
153 trace!(header = %header_str, "Processing Accept-Language header");
154
155 if let Some(lang) = parse_accept_language_optimized(
156 header_str,
157 &web_context.i18n_context.supported_languages
158 ) {
159 let normalized_lang = normalize_language_identifier(&lang);
160 debug!(language = %normalized_lang, "Using language from Accept-Language header");
161 return Ok(Self(normalized_lang));
162 }
163 }
164 }
165
166 // 4. Fall back to default language
167 let default_lang = &web_context.i18n_context.supported_languages[0];
168 let normalized_default = normalize_language_identifier(default_lang);
169 debug!(language = %normalized_default, "Using default language");
170 Ok(Self(normalized_default))
171 }
172}
173
174impl Language {
175 /// Create HTMX-compatible locale headers for enhanced frontend support
176 pub fn htmx_headers(&self, supported_languages: &[LanguageIdentifier]) -> Vec<(String, String)> {
177 let supported_locales: Vec<String> = supported_languages
178 .iter()
179 .map(|lang| lang.to_string())
180 .collect();
181
182 vec![
183 (HTMX_CURRENT_LOCALE.to_string(), self.0.to_string()),
184 (HTMX_SUPPORTED_LOCALES.to_string(), supported_locales.join(",")),
185 ]
186 }
187
188 /// Fast language matching with optimized comparison
189 pub fn matches_any(&self, candidates: &[LanguageIdentifier]) -> bool {
190 candidates.iter().any(|lang| lang.matches(&self.0, true, false))
191 }
192
193 /// Get the language code for cookie storage (optimized format)
194 pub fn to_cookie_value(&self) -> String {
195 self.0.to_string()
196 }
197}
198
199/// Optimized Accept-Language header parsing with early exit optimization
200fn parse_accept_language_optimized(
201 header_str: &str,
202 supported_languages: &[LanguageIdentifier]
203) -> Option<LanguageIdentifier> {
204 let mut best_match: Option<(LanguageIdentifier, f32)> = None;
205
206 // Parse and find the best match in a single pass
207 for lang_part in header_str.split(',') {
208 if let Ok(accepted_lang) = lang_part.parse::<AcceptedLanguage>() {
209 if let Ok(lang_id) = accepted_lang.value.parse::<LanguageIdentifier>() {
210 for supported_lang in supported_languages {
211 if lang_id.matches(supported_lang, true, false) {
212 // Update best match if this has higher quality
213 let should_update = match &best_match {
214 None => true,
215 Some((_, current_quality)) => accepted_lang.quality > *current_quality,
216 };
217
218 if should_update {
219 best_match = Some((supported_lang.clone(), accepted_lang.quality));
220 }
221 break; // Found a match for this language, move to next
222 }
223 }
224 }
225 }
226 }
227
228 best_match.map(|(lang, _)| lang)
229}
230
231/// Fast cookie language validation
232fn validate_cookie_language(
233 cookie_value: &str,
234 supported_languages: &[LanguageIdentifier]
235) -> Option<LanguageIdentifier> {
236 // Try exact match first (most common case)
237 if let Ok(lang_id) = cookie_value.parse::<LanguageIdentifier>() {
238 for supported_lang in supported_languages {
239 if supported_lang == &lang_id {
240 return Some(supported_lang.clone());
241 }
242 }
243
244 // Try fuzzy match if exact match fails (reverse the matching direction)
245 for supported_lang in supported_languages {
246 if lang_id.matches(supported_lang, true, false) {
247 return Some(supported_lang.clone());
248 }
249 }
250 }
251
252 None
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use unic_langid::langid;
259
260 #[test]
261 fn test_accept_language_parsing_optimization() {
262 let supported = vec![
263 langid!("en-US"),
264 langid!("fr-CA"),
265 langid!("es"),
266 ];
267
268 // Test exact match
269 let result = parse_accept_language_optimized("en-US,fr;q=0.9", &supported);
270 assert_eq!(result, Some(langid!("en-US")));
271
272 // Test quality preference
273 let result = parse_accept_language_optimized("fr;q=0.9,en-US;q=1.0", &supported);
274 assert_eq!(result, Some(langid!("en-US")));
275
276 // Test fallback to supported language
277 let result = parse_accept_language_optimized("de,fr-CA;q=0.8", &supported);
278 assert_eq!(result, Some(langid!("fr-CA")));
279
280 // Test no match
281 let result = parse_accept_language_optimized("de,it", &supported);
282 assert_eq!(result, None);
283 }
284
285 #[test]
286 fn test_cookie_language_validation() {
287 let supported = vec![
288 langid!("en-US"),
289 langid!("fr-CA"),
290 ];
291
292 // Test exact match
293 let result = validate_cookie_language("en-US", &supported);
294 assert_eq!(result, Some(langid!("en-US")));
295
296 // Test fuzzy match
297 let result = validate_cookie_language("en", &supported);
298 assert_eq!(result, Some(langid!("en-US")));
299
300 // Test no match
301 let result = validate_cookie_language("de", &supported);
302 assert_eq!(result, None);
303 }
304
305 #[test]
306 fn test_language_htmx_headers() {
307 let lang = Language::from(langid!("en-US"));
308 let supported = vec![langid!("en-US"), langid!("fr-CA")];
309
310 let headers = lang.htmx_headers(&supported);
311
312 assert_eq!(headers.len(), 2);
313 assert_eq!(headers[0], (HTMX_CURRENT_LOCALE.to_string(), "en-US".to_string()));
314 assert_eq!(headers[1], (HTMX_SUPPORTED_LOCALES.to_string(), "en-US,fr-CA".to_string()));
315 }
316
317 #[test]
318 fn test_language_matches_any() {
319 let lang = Language::from(langid!("en-US"));
320 let candidates = vec![langid!("en"), langid!("fr")];
321
322 assert!(lang.matches_any(&candidates));
323
324 let candidates = vec![langid!("de"), langid!("fr")];
325 assert!(!lang.matches_any(&candidates));
326 }
327
328 #[test]
329 fn test_accepted_language_parsing() {
330 let lang: AcceptedLanguage = "en-US;q=0.8".parse().unwrap();
331 assert_eq!(lang.value, "en-US");
332 assert_eq!(lang.quality, 0.8);
333
334 let lang: AcceptedLanguage = "fr".parse().unwrap();
335 assert_eq!(lang.value, "fr");
336 assert_eq!(lang.quality, 1.0);
337
338 // Test invalid quality clamping
339 let lang: AcceptedLanguage = "en;q=1.5".parse().unwrap();
340 assert_eq!(lang.quality, 1.0);
341 }
342
343 #[test]
344 fn test_accepted_language_ordering() {
345 let mut langs = vec![
346 AcceptedLanguage { value: "en".to_string(), quality: 0.8 },
347 AcceptedLanguage { value: "fr".to_string(), quality: 1.0 },
348 AcceptedLanguage { value: "de".to_string(), quality: 0.9 },
349 ];
350
351 langs.sort_by(|a, b| b.cmp(a));
352
353 assert_eq!(langs[0].value, "fr");
354 assert_eq!(langs[1].value, "de");
355 assert_eq!(langs[2].value, "en");
356 }
357
358 #[test]
359 fn debug_language_matching() {
360 use unic_langid::langid;
361 let en_us = langid!("en-US");
362 let en = "en".parse::<LanguageIdentifier>().unwrap();
363
364 println!("en_us: {:?}", en_us);
365 println!("en: {:?}", en);
366 println!("matches: {}", en_us.matches(&en, true, false));
367
368 // Try the other way around
369 println!("en matches en_us: {}", en.matches(&en_us, true, false));
370 }
371}