extractor.rs
296 lignes · 9923 octets
//! Locale extractor for Axum //! //! This module provides a `Locale` extractor that extracts the user's preferred //! locale from various sources (cookie, header, user preference). use axum::{ extract::FromRequestParts, http::{StatusCode, request::Parts}, }; /// User locale extracted from request /// /// Priority order: /// 1. User preference from database (if authenticated) /// 2. Cookie `locale` (if present) /// 3. Accept-Language header (for non-authenticated users) /// 4. Default: "en" #[derive(Debug, Clone)] pub struct Locale(pub String); impl Locale { /// Get the locale string pub fn as_str(&self) -> &str { &self.0 } /// Get the language code (first 2 characters) /// /// T106 (FR-005.3): This method extracts the base language from regional variants /// - "fr_CA" -> "fr" /// - "en_US" -> "en" /// - "fr" -> "fr" pub fn language(&self) -> &str { if self.0.len() >= 2 { &self.0[..2] } else { "en" } } /// Get the base language code, normalizing regional variants /// /// T106 (FR-005.3): Returns the base language for regional variants /// This is used for fallback when a regional variant translation is not available pub fn base_language(&self) -> String { normalize_to_base_language(&self.0) } } impl<S> FromRequestParts<S> for Locale where S: Send + Sync, { type Rejection = (StatusCode, String); async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> { // TODO: T009 - Implement full locale extraction logic // Priority order: // 1. User preference from database (requires database connection) // 2. Cookie `locale` // 3. Accept-Language header // 4. Default: "fr" (FR-001: French is the default language) // Check cookie first let cookie_jar = axum_extra::extract::CookieJar::from_headers(&parts.headers); if let Some(cookie) = cookie_jar.get("locale") { let locale = cookie.value().to_string(); if is_valid_language_code(&locale) { // T106 (FR-005.3): Regional variant fallback // If locale is a regional variant, we keep it as-is for now // The fallback to base language will happen in the service/loader if needed return Ok(Locale(locale)); } } // Check Accept-Language header // Enhanced error recovery: handle malformed headers gracefully if let Some(accept_lang) = parts.headers.get("accept-language") { // Try to convert header value to string // If conversion fails (invalid UTF-8), log and continue to default match accept_lang.to_str() { Ok(accept_lang_str) => { // Parse with enhanced error recovery if let Some(locale) = parse_accept_language(accept_lang_str) { // T106 (FR-005.3): Regional variant fallback // parse_accept_language already returns base language, so no additional fallback needed here return Ok(Locale(locale)); } // If parsing fails (no supported language), continue to default // This is expected behavior, not an error } Err(e) => { // Log malformed header but don't fail - gracefully fallback to default tracing::debug!( "Invalid Accept-Language header encoding: {}. Falling back to default locale.", e ); } } } // Default to French (FR-001: French is the default language) Ok(Locale("fr".to_string())) } } /// Check if a language code is valid and supported /// /// Supports both base language codes (fr, en, de, es) and regional variants (fr_CA, en_US, etc.) fn is_valid_language_code(code: &str) -> bool { // Extract base language (first 2 characters) let base_lang = if code.len() >= 2 { &code[..2] } else { return false; }; // Check if base language is supported matches!(base_lang, "fr" | "en" | "de" | "es" | "it" | "pt") } /// Normalize locale code to base language /// /// T106 (FR-005.3): Implement regional variant fallback /// Converts regional variants (e.g., "fr_CA", "en_US") to base language (e.g., "fr", "en") /// /// # Examples /// /// - "fr_CA" -> "fr" /// - "en_US" -> "en" /// - "de_DE" -> "de" /// - "fr" -> "fr" (no change) fn normalize_to_base_language(locale: &str) -> String { if locale.len() > 2 && locale.contains('_') { // Regional variant: extract base language locale[..2].to_string() } else { // Already base language or invalid locale.to_string() } } /// Parse Accept-Language header and return the first supported language /// /// This function handles malformed headers gracefully by: /// - Skipping invalid entries /// - Handling missing separators /// - Recovering from encoding issues /// - Processing quality values (q-values) correctly /// /// Returns the first language code that is supported, or None if none match. /// /// # Examples /// /// - "fr-FR,fr;q=0.9" -> Some("fr") /// - "en-US,en;q=0.8" -> Some("en") /// - "invalid,en-US" -> Some("en") (skips invalid, continues) /// - "fr,de,es" -> Some("fr") (first valid) fn parse_accept_language(header: &str) -> Option<String> { // Handle empty or whitespace-only headers let header = header.trim(); if header.is_empty() { return None; } // Split by comma to get language entries // Format: "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7" let entries: Vec<&str> = header.split(',').collect(); // Process each entry, skipping invalid ones for entry in entries { let entry = entry.trim(); if entry.is_empty() { continue; } // Split by semicolon to separate language from quality value // Format: "fr-FR;q=0.9" or just "fr-FR" let lang_part = entry.split(';').next()?.trim(); // Skip entries that are too short or contain invalid characters if lang_part.len() < 2 { continue; } // Extract language code (first 2 characters) // Handle both "fr-FR" and "fr" formats let lang = if lang_part.len() >= 2 { &lang_part[..2] } else { continue; }; // Validate language code (must be ASCII letters) if !lang.chars().all(|c| c.is_ascii_alphabetic()) { continue; } // Check if language is supported if is_valid_language_code(lang) { return Some(lang.to_string()); } } // No supported language found None } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_valid_language_code() { assert!(is_valid_language_code("fr")); assert!(is_valid_language_code("en")); assert!(is_valid_language_code("de")); assert!(is_valid_language_code("es")); assert!(is_valid_language_code("it")); assert!(is_valid_language_code("pt")); assert!(!is_valid_language_code("ja")); assert!(!is_valid_language_code("")); } #[test] fn test_parse_accept_language() { // Standard cases assert_eq!( parse_accept_language("fr-FR,fr;q=0.9"), Some("fr".to_string()) ); assert_eq!( parse_accept_language("en-US,en;q=0.8"), Some("en".to_string()) ); assert_eq!( parse_accept_language("de-DE,de;q=0.9"), Some("de".to_string()) ); assert_eq!( parse_accept_language("es-ES,es;q=0.9"), Some("es".to_string()) ); // Italian assert_eq!( parse_accept_language("it-IT,it;q=0.9"), Some("it".to_string()) ); // Unsupported languages assert_eq!(parse_accept_language("ja-JP,ja;q=0.9"), None); // Not supported assert_eq!(parse_accept_language("zh-CN,zh;q=0.9"), None); // Not supported // Empty and whitespace assert_eq!(parse_accept_language(""), None); assert_eq!(parse_accept_language(" "), None); // Malformed headers - should recover gracefully assert_eq!( parse_accept_language("invalid,en-US"), Some("en".to_string()) ); // Skip invalid, continue assert_eq!(parse_accept_language("x,fr-FR"), Some("fr".to_string())); // Skip too short, continue assert_eq!(parse_accept_language("12,en-US"), Some("en".to_string())); // Skip non-alphabetic, continue assert_eq!( parse_accept_language("fr-FR,invalid,en-US"), Some("fr".to_string()) ); // First valid wins // Multiple valid languages - should return first assert_eq!( parse_accept_language("fr-FR,de-DE,es-ES"), Some("fr".to_string()) ); assert_eq!(parse_accept_language("en-US,fr-FR"), Some("en".to_string())); // Language codes without region assert_eq!(parse_accept_language("fr,en"), Some("fr".to_string())); assert_eq!(parse_accept_language("de,es"), Some("de".to_string())); // Headers with extra whitespace assert_eq!( parse_accept_language(" fr-FR , en-US "), Some("fr".to_string()) ); assert_eq!( parse_accept_language("fr-FR ; q=0.9"), Some("fr".to_string()) ); // All invalid entries assert_eq!(parse_accept_language("invalid,xyz,123"), None); assert_eq!(parse_accept_language("xx,yy,zz"), None); } }
GitRust