lingva 0.1.0

Standalone Lingva localization CLI
use std::collections::BTreeMap;

use anyhow::{Context, Result, bail};
use reqwest::blocking::{Client, Response};
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};

use crate::{
    config::Config,
    local::{Dictionary, build_bundle},
};

const JSON_MEDIA_TYPE: &str = "application/json";
const API_KEY_HEADER: &str = "x-api-key";
const REVISION_HEADER: &str = "x-lingva-revision";
const PUBLISH_ACTION: &str = "publish";
const BUNDLE_PATH: &str = "/bundles/{projectId}/{environment}/latest/{locale}.bundle.json";

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthDiscovery {
    pub issuer_url: String,
    pub client_id: String,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlatformEnvironment {
    pub environment_id: String,
    pub publish_api_url: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlatformProject {
    pub project_id: String,
    pub name: String,
    pub locales: Vec<String>,
    pub default_locale: String,
    #[serde(default)]
    pub environments: Vec<PlatformEnvironment>,
}

#[derive(Debug, Deserialize)]
struct ProjectList {
    projects: Vec<PlatformProject>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct PushPayload {
    action: &'static str,
    manifest: Value,
    bundles: BTreeMap<String, Value>,
    mutation_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    base_revision: Option<Option<String>>,
}

pub struct LingvaApi {
    client: Client,
}

impl LingvaApi {
    pub fn new() -> Result<Self> {
        Ok(Self {
            client: Client::builder()
                .user_agent(format!("lingva/{}", env!("CARGO_PKG_VERSION")))
                .build()?,
        })
    }

    pub fn discover_auth(&self, platform_url: &str) -> Result<AuthDiscovery> {
        self.get_json(
            &format!("{}/api/cli/config", platform_url.trim_end_matches('/')),
            None,
        )
    }

    pub fn list_projects(
        &self,
        platform_url: &str,
        access_token: &str,
    ) -> Result<Vec<PlatformProject>> {
        Ok(self
            .get_json::<ProjectList>(
                &format!("{}/api/cli/projects", platform_url.trim_end_matches('/')),
                Some(access_token),
            )?
            .projects)
    }

    pub fn pull_bundle(
        &self,
        config: &Config,
        locale: &str,
        token: Option<&str>,
    ) -> Result<(Value, String)> {
        let base = config
            .api_url
            .as_deref()
            .context("Lingva pull requires \"apiUrl\" in lingva.config.yaml.")?;
        let path = BUNDLE_PATH
            .replace("{projectId}", &config.project_id)
            .replace("{environment}", &config.environment)
            .replace("{locale}", locale);
        let response = self
            .request(
                self.client
                    .get(format!("{}{path}", base.trim_end_matches('/'))),
                token,
            )?
            .send()?;
        let response = ensure_success(response)?;
        let revision = response
            .headers()
            .get(REVISION_HEADER)
            .and_then(|value| value.to_str().ok())
            .context("Lingva hosted pull response has no revision header.")?
            .to_owned();
        Ok((response.json()?, revision))
    }

    pub fn push(
        &self,
        config: &Config,
        locales: &[String],
        token: Option<&str>,
        base_revision: Option<String>,
        force: bool,
    ) -> Result<(Value, BTreeMap<String, Dictionary>)> {
        let api_url = config
            .api_url
            .as_deref()
            .context("Lingva push requires \"apiUrl\" in lingva.config.yaml.")?;
        let resources = config
            .locales
            .iter()
            .map(|locale| {
                Ok((
                    locale.clone(),
                    crate::local::read_dictionary(&config.translation_path(locale))?,
                ))
            })
            .collect::<Result<BTreeMap<_, _>>>()?;
        let generated_at = chrono::Utc::now().to_rfc3339();
        let mut bundles = BTreeMap::new();
        for locale in locales {
            let mut bundle = build_bundle(config, locale, &resources)?;
            bundle["generatedAt"] = json!(generated_at);
            bundles.insert(locale.clone(), bundle);
        }
        let descriptors = locales.iter().map(|locale| json!({
            "locale": locale,
            "fileName": format!("{locale}.bundle.json"),
            "keyCount": bundles[locale]["keyCount"],
            "fallbackKeyCount": bundles[locale]["fallbackKeys"].as_array().map_or(0, Vec::len),
        })).collect::<Vec<_>>();
        let manifest = json!({
            "projectId": config.project_id,
            "environment": config.environment,
            "defaultLocale": config.default_locale,
            "fallbackLocale": config.fallback_locale,
            "generatedAt": generated_at,
            "locales": locales,
            "bundles": descriptors,
        });
        let mutation_id = mutation_id(&manifest, &bundles)?;
        let payload = PushPayload {
            action: PUBLISH_ACTION,
            manifest,
            bundles,
            mutation_id,
            base_revision: (!force).then_some(base_revision),
        };
        let response = self
            .request(self.client.post(api_url).json(&payload), token)?
            .send()?;
        let result = ensure_success(response)?.json()?;
        Ok((result, resources))
    }

    fn get_json<T: DeserializeOwned>(&self, url: &str, token: Option<&str>) -> Result<T> {
        let response = self.request(self.client.get(url), token)?.send()?;
        Ok(ensure_success(response)?.json()?)
    }

    fn request(
        &self,
        request: reqwest::blocking::RequestBuilder,
        token: Option<&str>,
    ) -> Result<reqwest::blocking::RequestBuilder> {
        let mut headers = HeaderMap::new();
        headers.insert(ACCEPT, HeaderValue::from_static(JSON_MEDIA_TYPE));
        headers.insert(CONTENT_TYPE, HeaderValue::from_static(JSON_MEDIA_TYPE));
        if let Some(token) = token {
            headers.insert(
                AUTHORIZATION,
                HeaderValue::from_str(&format!("Bearer {token}"))?,
            );
            headers.insert(API_KEY_HEADER, HeaderValue::from_str(token)?);
        }
        Ok(request.headers(headers))
    }
}

fn ensure_success(response: Response) -> Result<Response> {
    if response.status().is_success() {
        return Ok(response);
    }
    let status = response.status();
    let detail = response.text().unwrap_or_default();
    bail!(
        "Lingva hosted request failed with HTTP {}{}",
        status.as_u16(),
        if detail.trim().is_empty() {
            ".".into()
        } else {
            format!(": {}", detail.trim())
        }
    );
}

fn mutation_id(manifest: &Value, bundles: &BTreeMap<String, Value>) -> Result<String> {
    let mut semantic_manifest = manifest.clone();
    semantic_manifest
        .as_object_mut()
        .map(|object| object.remove("generatedAt"));
    let semantic_bundles = bundles
        .iter()
        .map(|(locale, bundle)| {
            let mut bundle = bundle.clone();
            bundle
                .as_object_mut()
                .map(|object| object.remove("generatedAt"));
            (locale.clone(), canonicalize(bundle))
        })
        .collect::<BTreeMap<_, _>>();
    let canonical =
        canonicalize(json!({"manifest": semantic_manifest, "bundles": semantic_bundles}));
    Ok(format!(
        "sha256:{:x}",
        Sha256::digest(serde_json::to_vec(&canonical)?)
    ))
}

fn canonicalize(value: Value) -> Value {
    match value {
        Value::Object(object) => Value::Object(
            object
                .into_iter()
                .map(|(key, value)| (key, canonicalize(value)))
                .collect::<BTreeMap<_, _>>()
                .into_iter()
                .collect::<Map<_, _>>(),
        ),
        Value::Array(items) => Value::Array(items.into_iter().map(canonicalize).collect()),
        value => value,
    }
}

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

    #[test]
    fn mutation_ignores_generated_at() {
        let left = mutation_id(
            &json!({"generatedAt":"one","projectId":"p"}),
            &BTreeMap::from([(
                "en".into(),
                json!({"generatedAt":"one","messages":{"a":"A"}}),
            )]),
        )
        .unwrap();
        let right = mutation_id(
            &json!({"generatedAt":"two","projectId":"p"}),
            &BTreeMap::from([(
                "en".into(),
                json!({"generatedAt":"two","messages":{"a":"A"}}),
            )]),
        )
        .unwrap();
        assert_eq!(left, right);
    }
}