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