i18n+filtering fork - fluent-templates v2
at main 371 lines 12 kB view raw
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}