Skip to main content

modo/i18n/
extractor.rs

1use axum::extract::FromRequestParts;
2use http::request::Parts;
3
4use super::store::TranslationStore;
5
6/// Per-request translator handle.
7///
8/// Holds the resolved request locale and a handle to the shared
9/// [`TranslationStore`]. Produced by [`I18nLayer`](super::I18nLayer) and
10/// extracted by handlers through the axum extractor impl below, or built
11/// directly via [`I18n::translator`](super::I18n::translator) for non-request
12/// contexts.
13///
14/// Cheaply cloneable — `TranslationStore` is an `Arc` internally and `locale`
15/// is a short `String`.
16#[derive(Clone, Debug)]
17pub struct Translator {
18    locale: String,
19    store: TranslationStore,
20}
21
22impl Translator {
23    /// Constructs a new translator for `locale` backed by `store`.
24    pub(super) fn new(locale: String, store: TranslationStore) -> Self {
25        Self { locale, store }
26    }
27
28    /// Translates `key`, interpolating any `{placeholder}` values from `kwargs`.
29    ///
30    /// Falls back to the default locale and then to the key itself if no entry
31    /// is found. Never panics.
32    pub fn t(&self, key: &str, kwargs: &[(&str, &str)]) -> String {
33        self.store
34            .translate(&self.locale, key, kwargs)
35            .unwrap_or_else(|_| key.to_string())
36    }
37
38    /// Translates `key` with plural-rule selection based on `count`.
39    ///
40    /// `count` is also injected into `kwargs` under the name `count`.
41    pub fn t_plural(&self, key: &str, count: i64, kwargs: &[(&str, &str)]) -> String {
42        self.store
43            .translate_plural(&self.locale, key, count, kwargs)
44            .unwrap_or_else(|_| key.to_string())
45    }
46
47    /// Returns the resolved locale for this request.
48    pub fn locale(&self) -> &str {
49        &self.locale
50    }
51
52    /// Returns the shared [`TranslationStore`] this translator reads from.
53    pub fn store(&self) -> &TranslationStore {
54        &self.store
55    }
56}
57
58impl<S: Send + Sync> FromRequestParts<S> for Translator {
59    type Rejection = crate::Error;
60
61    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
62        parts
63            .extensions
64            .get::<Translator>()
65            .cloned()
66            .ok_or_else(|| {
67                crate::Error::internal("I18nLayer not installed").with_code("i18n:layer_missing")
68            })
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use crate::i18n::config::I18nConfig;
76    use crate::i18n::factory::I18n;
77    use axum::extract::FromRequestParts;
78    use http::{Request, StatusCode};
79
80    fn test_i18n() -> I18n {
81        let dir = tempfile::tempdir().unwrap();
82        let en_dir = dir.path().join("en");
83        std::fs::create_dir_all(&en_dir).unwrap();
84        std::fs::write(en_dir.join("common.yaml"), "greeting: Hello").unwrap();
85
86        let config = I18nConfig {
87            locales_path: dir.path().to_str().unwrap().to_string(),
88            default_locale: "en".into(),
89            ..I18nConfig::default()
90        };
91        I18n::new(&config).unwrap()
92    }
93
94    #[tokio::test]
95    async fn missing_extension_returns_internal_error() {
96        let req = Request::builder().body(()).unwrap();
97        let (mut parts, _) = req.into_parts();
98        let err = Translator::from_request_parts(&mut parts, &())
99            .await
100            .expect_err("should return Err when extension missing");
101        // Error::internal maps to 500.
102        let resp = axum::response::IntoResponse::into_response(err);
103        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
104    }
105
106    #[tokio::test]
107    async fn present_extension_returns_translator() {
108        let i18n = test_i18n();
109        let translator = i18n.translator("en");
110
111        let req = Request::builder().body(()).unwrap();
112        let (mut parts, _) = req.into_parts();
113        parts.extensions.insert(translator);
114
115        let extracted = Translator::from_request_parts(&mut parts, &())
116            .await
117            .expect("extraction should succeed");
118
119        assert_eq!(extracted.locale(), "en");
120        assert_eq!(extracted.t("common.greeting", &[]), "Hello");
121        assert_eq!(extracted.store().default_locale(), "en");
122    }
123}