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);
    }
}