rustango 0.60.7

Batteries-included web framework for Rust: ORM + migrations + auto-admin + multi-tenancy + audit log + auth (sessions, JWT, OAuth2/OIDC, HMAC) + APIs (ViewSet, OpenAPI auto-derive, JSON:API) + jobs (in-mem + Postgres) + email + media (S3 / R2 / B2 / MinIO + presigned uploads + collections + tags) + production middleware (CSRF, CSP, rate-limiting, compression, idempotency, etc.).
Documentation
//! `LocaleMiddleware` — a tower layer that picks the active locale
//! per request and injects it into request extensions.
//!
//! The pick order, excluding URL prefix (see the note below):
//!
//! 1. Session / cookie. The name is configurable; it defaults to
//!    `django_language`, which stays as-is because deployed clients
//!    already send that cookie.
//! 2. `Accept-Language` header via [`super::negotiate_language`].
//! 3. The configured default locale.
//!
//! ## Quick start
//!
//! ```ignore
//! use rustango::i18n::middleware::{LocaleMiddleware, ActiveLocale};
//!
//! let layer = LocaleMiddleware::new(&["en", "fr", "es"])
//!     .default("en")
//!     .cookie_name("django_language".to_string());
//!
//! let app = axum::Router::new()
//!     .route("/", get(handler))
//!     .layer(layer);
//!
//! async fn handler(active: ActiveLocale) -> String {
//!     format!("you speak {}", active.0)
//! }
//! ```
//!
//! ## URL-prefix locale (`/en/foo` / `/es/foo`)
//!
//! axum's `Router::layer` runs **after** path matching, so a layer
//! can't strip `/en` before routing. Use `Router::nest` per locale
//! instead — composes with `LocaleMiddleware` cleanly:
//!
//! ```ignore
//! use axum::Router;
//! use rustango::i18n::middleware::LocaleMiddleware;
//!
//! fn locale_router(lang: &str) -> Router {
//!     Router::new()
//!         .route("/posts", get(list_posts))
//!         // Hard-code the locale for routes under this nest:
//!         .layer(LocaleMiddleware::new(&[lang]).default(lang))
//! }
//!
//! let app = Router::new()
//!     .nest("/en", locale_router("en"))
//!     .nest("/fr", locale_router("fr"));
//! ```
//!
//! That is how you get locale-prefixed routes: compose
//! `Router::nest` once per locale.

use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context as TaskContext, Poll};

use axum::body::Body;
use axum::extract::FromRequestParts;
use axum::http::{Request, Response};

use super::{negotiate_language, Locale};

const DEFAULT_COOKIE: &str = "django_language";

/// The locale picked for the current request. Available as an
/// extractor on handler signatures; populated by
/// [`LocaleMiddleware`]. Falls back to `"en"` when no middleware ran.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveLocale(pub String);

impl ActiveLocale {
    /// Read the active locale from request extensions, if any was
    /// installed by [`LocaleMiddleware`].
    pub fn from_extensions(ext: &axum::http::Extensions) -> Option<Self> {
        ext.get::<Self>().cloned()
    }

    /// `true` for right-to-left scripts (#429). Convenience that
    /// routes through [`crate::i18n::is_rtl_language`] so handler
    /// code can branch on bidi without re-importing the helper.
    #[must_use]
    pub fn is_rtl(&self) -> bool {
        super::is_rtl_language(&self.0)
    }

    /// `"rtl"` / `"ltr"` for the active locale — feed directly to an
    /// HTML `dir` attribute or CSS `direction:`.
    #[must_use]
    pub fn direction(&self) -> &'static str {
        super::text_direction(&self.0)
    }
}

impl<S: Send + Sync> FromRequestParts<S> for ActiveLocale {
    type Rejection = std::convert::Infallible;

    async fn from_request_parts(
        parts: &mut axum::http::request::Parts,
        _state: &S,
    ) -> Result<Self, Self::Rejection> {
        Ok(parts
            .extensions
            .get::<ActiveLocale>()
            .cloned()
            .unwrap_or_else(|| ActiveLocale("en".into())))
    }
}

#[derive(Clone)]
struct LocaleConfig {
    /// Supported locales, normalised by [`Locale::new`].
    available: Vec<String>,
    /// Fallback locale when nothing matches, normalised the same way.
    default: String,
    /// Cookie name to read; `None` to disable cookie lookup.
    cookie_name: Option<String>,
}

/// Tower layer that resolves an [`ActiveLocale`] per request.
#[derive(Clone)]
pub struct LocaleMiddleware {
    config: Arc<LocaleConfig>,
}

impl LocaleMiddleware {
    /// Build a middleware that picks among `available` locales. The
    /// first entry becomes the default; override with
    /// [`Self::default`].
    #[must_use]
    pub fn new(available: &[&str]) -> Self {
        let avail: Vec<String> = available.iter().map(|s| Locale::new(*s).0).collect();
        let default = avail.first().cloned().unwrap_or_else(|| "en".into());
        Self {
            config: Arc::new(LocaleConfig {
                available: avail,
                default,
                cookie_name: Some(DEFAULT_COOKIE.into()),
            }),
        }
    }

    /// Override the fallback locale.
    #[must_use]
    pub fn default(mut self, locale: &str) -> Self {
        Arc::make_mut(&mut self.config).default = Locale::new(locale).0;
        self
    }

    /// Set the cookie name to read for a sticky locale preference.
    /// Pass `None` to disable cookie lookup.
    #[must_use]
    pub fn cookie_name(mut self, name: impl Into<Option<String>>) -> Self {
        Arc::make_mut(&mut self.config).cookie_name = name.into();
        self
    }

    /// Run the pick algorithm against a request. Pure — exposed for
    /// unit tests + direct use from custom middleware compositions.
    pub fn pick(&self, req: &Request<Body>) -> String {
        let cfg = &self.config;

        // 1. Cookie
        if let Some(name) = cfg.cookie_name.as_deref() {
            if let Some(value) = crate::cookies::cookie_from_headers(req.headers(), name) {
                // Same form as the Accept-Language path: `pt_BR` ≡ `pt-BR`.
                let locale = Locale::new(value).0;
                if cfg.available.contains(&locale) {
                    return locale;
                }
            }
        }

        // 2. Accept-Language
        if let Some(al) = req.headers().get(axum::http::header::ACCEPT_LANGUAGE) {
            if let Ok(value) = al.to_str() {
                if let Some(matched) = negotiate_language(value, &cfg.available) {
                    return matched;
                }
            }
        }

        // 3. Default fallback
        cfg.default.clone()
    }
}

impl<S> tower::Layer<S> for LocaleMiddleware {
    type Service = LocaleService<S>;

    fn layer(&self, inner: S) -> Self::Service {
        LocaleService {
            inner,
            middleware: self.clone(),
        }
    }
}

#[derive(Clone)]
pub struct LocaleService<S> {
    inner: S,
    middleware: LocaleMiddleware,
}

impl<S> tower::Service<Request<Body>> for LocaleService<S>
where
    S: tower::Service<Request<Body>, Response = Response<Body>> + Clone + Send + 'static,
    S::Future: Send + 'static,
{
    type Response = S::Response;
    type Error = S::Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;

    fn poll_ready(&mut self, cx: &mut TaskContext<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, mut req: Request<Body>) -> Self::Future {
        let picked = self.middleware.pick(&req);
        req.extensions_mut().insert(ActiveLocale(picked));
        let by_cookie = self.middleware.config.cookie_name.is_some();
        let fut = self.inner.call(req);
        // The locale came from these headers, so a shared cache must key on them (#1924).
        Box::pin(async move {
            let mut resp = fut.await?;
            crate::vary::add_vary(resp.headers_mut(), "Accept-Language");
            if by_cookie {
                crate::vary::add_vary(resp.headers_mut(), "Cookie");
            }
            Ok(resp)
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn req(uri: &str, accept_language: Option<&str>, cookie: Option<&str>) -> Request<Body> {
        let mut b = Request::builder().uri(uri);
        if let Some(al) = accept_language {
            b = b.header(axum::http::header::ACCEPT_LANGUAGE, al);
        }
        if let Some(c) = cookie {
            b = b.header(axum::http::header::COOKIE, c);
        }
        b.body(Body::empty()).unwrap()
    }

    /// #1924 — the response names the headers the locale was picked from.
    #[tokio::test]
    async fn response_varies_on_accept_language_and_cookie() {
        use tower::{Layer as _, ServiceExt as _};
        let svc = tower::service_fn(|_req: Request<Body>| async {
            Ok::<_, std::convert::Infallible>(Response::new(Body::empty()))
        });
        let mw = LocaleMiddleware::new(&["en", "fr"]);
        let resp = mw
            .layer(svc)
            .oneshot(req("/", Some("fr"), None))
            .await
            .unwrap();
        assert_eq!(resp.headers()["vary"], "Accept-Language, Cookie");

        let mw = LocaleMiddleware::new(&["en", "fr"]).cookie_name(None);
        let resp = mw
            .layer(svc)
            .oneshot(req("/", Some("fr"), None))
            .await
            .unwrap();
        assert_eq!(resp.headers()["vary"], "Accept-Language");
    }

    // ---- #429 RTL convenience on the extractor ----

    #[test]
    fn active_locale_direction_rtl_for_arabic() {
        let al = ActiveLocale("ar".into());
        assert!(al.is_rtl());
        assert_eq!(al.direction(), "rtl");
    }

    #[test]
    fn active_locale_direction_ltr_for_english() {
        let al = ActiveLocale("en-US".into());
        assert!(!al.is_rtl());
        assert_eq!(al.direction(), "ltr");
    }

    #[test]
    fn cookie_beats_accept_language() {
        let mw = LocaleMiddleware::new(&["en", "fr"]).default("en");
        let r = req("/", Some("en"), Some("django_language=fr"));
        assert_eq!(mw.pick(&r), "fr");
    }

    #[test]
    fn accept_language_used_when_no_cookie() {
        let mw = LocaleMiddleware::new(&["en", "fr"]).default("en");
        let r = req("/", Some("fr-FR,fr;q=0.9"), None);
        assert_eq!(mw.pick(&r), "fr");
    }

    #[test]
    fn default_when_nothing_matches() {
        let mw = LocaleMiddleware::new(&["en", "fr"]).default("en");
        let r = req("/", Some("ja"), None);
        assert_eq!(mw.pick(&r), "en");
    }

    #[test]
    fn cookie_and_header_agree_on_underscore_locales() {
        let mw = LocaleMiddleware::new(&["en", "pt_BR"]).default("EN");
        assert_eq!(
            mw.pick(&req("/", None, Some("django_language=pt_BR"))),
            "pt-br"
        );
        assert_eq!(
            mw.pick(&req("/", None, Some("django_language=pt-BR"))),
            "pt-br"
        );
        assert_eq!(mw.pick(&req("/", Some("pt-BR"), None)), "pt-br");
        let mw = LocaleMiddleware::new(&["pt-BR"]).default("pt_BR");
        assert_eq!(mw.pick(&req("/", None, None)), "pt-br");
    }

    #[test]
    fn unknown_cookie_value_falls_through() {
        // Cookie set to a non-available locale — middleware should
        // ignore it and fall through to Accept-Language.
        let mw = LocaleMiddleware::new(&["en", "fr"]).default("en");
        let r = req("/", Some("fr"), Some("django_language=de"));
        assert_eq!(mw.pick(&r), "fr");
    }

    #[test]
    fn disabled_cookie_skips_cookie_step() {
        let mw = LocaleMiddleware::new(&["en", "fr"])
            .default("en")
            .cookie_name(None);
        // Cookie says fr but cookie lookup is disabled — Accept-Language
        // points at en, so en should win.
        let r = req("/", Some("en"), Some("django_language=fr"));
        assert_eq!(mw.pick(&r), "en");
    }

    #[test]
    fn case_insensitive_locale_lookup() {
        let mw = LocaleMiddleware::new(&["en", "fr"]).default("en");
        let r = req("/", None, Some("django_language=FR"));
        assert_eq!(mw.pick(&r), "fr");
    }
}