sofie-http 0.4.0

The HTTP server, areas, routes and the handler model.
Documentation
use http::header::ACCEPT_LANGUAGE;
use http::request::Parts;
use std::borrow::Cow;

use sofie_core::{Error, FieldError, KeyedRule, Result};
use sofie_db::File;
use sofie_engine::rendering::Rendering;
use sofie_engine::{Template, Tone, TranslationError, Translations};

use crate::Screens;

/// As `sofie gen` writes them: the source language first, then each module's
/// `lang/<language>.toml` files; `enums` by their `enum.<name>.<variant>` names, so badges
/// and selects show their variants translated.
pub fn translations(
    languages: &[&str],
    files: &[(&str, File)],
    builtin: &[(&str, &[File])],
    enums: &[(&str, &[(&str, Tone)])],
) -> Result<Translations> {
    let refused =
        |error: TranslationError| Error::internal("loading the app's translations", error);
    let mut translations = Translations::new(languages).map_err(refused)?;
    for (module, file) in files {
        translations
            .add(module, file.stem(), file.text()?)
            .map_err(refused)?;
    }
    for (module, files) in builtin {
        let sources = files
            .iter()
            .map(|file| Ok((file.stem(), file.text()?)))
            .collect::<Result<Vec<_>>>()?;
        translations
            .add_builtin(module, &sources)
            .map_err(refused)?;
    }
    for (name, variants) in enums {
        translations.add_enum(name, variants).map_err(refused)?;
    }
    Ok(translations)
}

impl Screens {
    /// `sofie check` refuses a screen using a key the source language lacks, as the app is built.
    pub fn translate(&mut self, translations: Translations) {
        self.translations = Some(translations);
    }

    /// The signed-in account's `preferred` language first, when the app has it.
    pub(crate) fn language(&self, request: &Parts, preferred: Option<&str>) -> Language<'_> {
        let accepted = request
            .headers
            .get(ACCEPT_LANGUAGE)
            .and_then(|value| value.to_str().ok());
        let preferred = preferred.and_then(|tag| sofie_values::Locale::parse(tag).ok());
        match &self.translations {
            Some(translations) => Language {
                language: translations.negotiate(preferred.as_ref(), accepted),
                negotiated: true,
            },
            None => Language::plain(),
        }
    }
}

/// `negotiated`: a cache must keep answers apart by `Accept-Language`.
#[derive(Clone, Copy)]
pub(crate) struct Language<'a> {
    language: sofie_engine::Language<'a>,
    negotiated: bool,
}

impl<'a> Language<'a> {
    pub(crate) fn plain() -> Language<'static> {
        Language {
            language: sofie_engine::Language::plain(),
            negotiated: false,
        }
    }

    pub(crate) fn varies_by(self) -> &'static str {
        if self.negotiated {
            return "Accept, Accept-Language, HX-Request, HX-Target";
        }
        crate::screen::VARIES_BY
    }

    pub(crate) fn screen<'t>(self, template: &'t Template) -> Rendering<'t>
    where
        'a: 't,
    {
        Rendering::new(template).language(self.language)
    }

    pub(crate) fn tag(self) -> &'a str {
        self.language.locale().as_str()
    }

    pub(crate) fn field_error(self, error: &FieldError) -> String {
        self.text(&error.key, &error.arguments, &error.message)
    }

    pub(crate) fn rule(self, rule: &KeyedRule) -> String {
        self.text(&rule.key, &rule.arguments, &rule.message)
    }

    fn text(self, key: &str, arguments: &[(Cow<'static, str>, String)], english: &str) -> String {
        let arguments: Vec<(&str, String)> = arguments
            .iter()
            .map(|(name, value)| (name.as_ref(), value.clone()))
            .collect();
        self.language.field_error(key, &arguments, english)
    }

    pub(crate) fn label(self, field: &str) -> String {
        let key = format!("label.{field}");
        match self.language.field_message(&key) {
            text if text == key => sofie_engine::humanize(field),
            text => text.to_owned(),
        }
    }

    pub(crate) fn script_notices(self) -> sofie_engine::ScriptNotices<'a> {
        sofie_engine::ScriptNotices::new(self.language)
    }

    pub(crate) fn fields_notice(self, count: usize) -> String {
        let summary = match count {
            1 => self.language.field_error("notice.correct_field", &[], ""),
            count => self.language.field_error(
                "notice.correct_fields",
                &[("count", count.to_string())],
                "",
            ),
        };
        sofie_engine::notice(&[summary])
    }
}

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

    fn turkish() -> Translations {
        translations(
            &["en", "tr"],
            &[(
                "order",
                File::new("lang/tr.toml", b"[label]\nstatus = \"Durum\"\n"),
            )],
            &[],
            &[],
        )
        .unwrap()
    }

    #[test]
    fn the_fields_notice_counts_in_the_viewer_s_language() {
        let translations = turkish();
        let language = Language {
            language: translations.negotiate(None, Some("tr-TR, en;q=0.5")),
            negotiated: true,
        };

        assert_eq!(language.tag(), "tr");
        assert_eq!(
            language.fields_notice(3),
            "<p>Lütfen işaretli 3 alanı düzeltin.</p>"
        );
        assert_eq!(
            Language::plain().fields_notice(1),
            "<p>Please correct the highlighted field.</p>"
        );
    }

    #[test]
    fn a_label_is_translated_or_humanized() {
        let translations = turkish();
        let language = Language {
            language: translations.negotiate(None, Some("tr")),
            negotiated: true,
        };

        assert_eq!(language.label("status"), "Durum");
        assert_eq!(language.label("placed_at"), "Placed at");
        let error =
            FieldError::new("total", "validation.max", "must be at most 5").with("max", "5");
        assert_eq!(language.field_error(&error), "en fazla 5 olmalı");
    }

    #[test]
    fn a_json_refusal_reads_in_the_viewer_s_language() {
        let translations = turkish();
        let language = Language {
            language: translations.negotiate(None, Some("tr")),
            negotiated: true,
        };
        let refusal = FieldError::new(
            "total.gt",
            "validation.kind.whole_number",
            "must be a whole number",
        );

        let translated = crate::answer::in_language(Err(Error::Invalid(vec![refusal])), language);

        let Err(Error::Invalid(fields)) = translated else {
            panic!("{:?}", translated.err());
        };
        assert_eq!(fields[0].field, "total.gt");
        assert_eq!(fields[0].message, "tam sayı olmalı");
    }

    #[test]
    fn a_keyed_rule_is_answered_in_the_viewer_s_language_with_its_key() {
        let translations = translations(
            &["en", "tr"],
            &[(
                "order",
                File::new(
                    "lang/tr.toml",
                    "[order.rule]\nshipped = \"{number} gönderildi\"\n".as_bytes(),
                ),
            )],
            &[],
            &[],
        )
        .unwrap();
        let language = Language {
            language: translations.negotiate(None, Some("tr")),
            negotiated: true,
        };
        let shipped = || {
            Error::rule_keyed(
                "order.rule.shipped",
                [("number", "7".to_owned())],
                "7 shipped",
            )
        };

        let json = crate::answer::in_language(Err(shipped()), language).unwrap_err();
        let json = crate::answer::failure(json, crate::caller::Caller::Browser);
        let notice = crate::answer::Refusal::of(shipped()).messages(language);

        assert_eq!(json.status(), http::StatusCode::UNPROCESSABLE_ENTITY);
        let body = json.into_body().into_inner().unwrap();
        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
        assert_eq!(
            body,
            serde_json::json!({
                "error": "7 gönderildi",
                "key": "order.rule.shipped",
                "arguments": { "number": "7" },
            })
        );
        assert_eq!(notice, ["7 gönderildi"]);
    }

    #[test]
    fn an_undeclared_language_file_is_refused() {
        let error = translations(
            &["en"],
            &[("order", File::new("lang/tr.toml", b""))],
            &[],
            &[],
        )
        .unwrap_err();

        assert!(
            error.to_string().contains("loading the app's translations"),
            "{error}"
        );
    }
}