zerodev-kora 3.1.1

Official Rust SDK for Kora licensing
Documentation
use std::fmt;
use std::time::Duration;

use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use base64::Engine;
use chrono::{DateTime, Utc};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::{json, Map, Value};

#[derive(Debug)]
pub enum Error {
    Http(String),
    Status(u16, String),
    Signature,
    Offline(String),
    Json(String),
}

impl fmt::Display for Error {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Error::Http(message) => write!(formatter, "Request to Kora failed: {message}"),
            Error::Status(status, message) => write!(formatter, "{message} ({status})"),
            Error::Signature => write!(formatter, "Response signature verification failed"),
            Error::Offline(message) => write!(formatter, "{message}"),
            Error::Json(message) => write!(formatter, "Kora sent an unexpected answer: {message}"),
        }
    }
}

impl std::error::Error for Error {}

#[derive(Debug, Deserialize)]
pub struct Validation {
    pub valid: bool,
    pub code: String,
    pub message: String,
    pub license: Option<Value>,
    pub timestamp: i64,
}

#[derive(Debug, Deserialize)]
pub struct Deactivation {
    pub success: bool,
    pub code: String,
    pub message: String,
    pub removed: i64,
    pub timestamp: i64,
}

pub struct Client {
    base: String,
    api_key: Option<String>,
    product: Option<String>,
    public_key: Option<String>,
    agent: ureq::Agent,
}

pub struct Reply {
    pub status: u16,
    pub text: String,
    pub signature: Option<String>,
}

impl Client {
    pub fn new(url: &str) -> Self {
        let trimmed = url.trim_end_matches('/');
        Client {
            base: match trimmed.strip_suffix("/api/v1") {
                Some(base) => base.to_string(),
                None => trimmed.to_string(),
            },
            api_key: None,
            product: None,
            public_key: None,
            agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(15)).build(),
        }
    }

    pub fn api_key(mut self, key: impl Into<String>) -> Self {
        self.api_key = Some(key.into());
        self
    }

    pub fn product(mut self, product: impl Into<String>) -> Self {
        self.product = Some(product.into());
        self
    }

    pub fn public_key(mut self, key: impl Into<String>) -> Self {
        self.public_key = Some(key.into());
        self
    }

    pub fn licenses(&self) -> Resource<'_> {
        Resource { client: self, name: "licenses", single: "license" }
    }

    pub fn products(&self) -> Resource<'_> {
        Resource { client: self, name: "products", single: "product" }
    }

    pub fn customers(&self) -> Resource<'_> {
        Resource { client: self, name: "customers", single: "customer" }
    }

    pub fn send(&self, method: &str, path: &str, query: &[(&str, &str)], body: Option<Value>) -> Result<Reply, Error> {
        let mut request = self.agent.request(method, &format!("{}/api/v1{}", self.base, path)).set("Accept", "application/json");
        if let Some(key) = &self.api_key {
            request = request.set("Authorization", &format!("Bearer {key}"));
        }
        for (name, value) in query {
            request = request.query(name, value);
        }
        let result = match body {
            Some(value) => request.send_json(value),
            None => request.call(),
        };
        let response = match result {
            Ok(response) => response,
            Err(ureq::Error::Status(_, response)) => response,
            Err(error) => return Err(Error::Http(error.to_string())),
        };
        let status = response.status();
        let signature = response.header("x-kora-signature").map(str::to_string);
        let text = response.into_string().map_err(|error| Error::Http(error.to_string()))?;
        Ok(Reply { status, text, signature })
    }

    fn fail(reply: &Reply) -> Error {
        let message = serde_json::from_str::<Value>(&reply.text).ok().and_then(|value| value.get("error").and_then(Value::as_str).map(str::to_string));
        match message {
            Some(text) => Error::Status(reply.status, text),
            None => Error::Status(reply.status, format!("Kora answered with status {}", reply.status)),
        }
    }

    pub fn call(&self, method: &str, path: &str, query: &[(&str, &str)], body: Option<Value>) -> Result<Value, Error> {
        let reply = self.send(method, path, query, body)?;
        if reply.status >= 400 {
            return Err(Self::fail(&reply));
        }
        if reply.text.is_empty() {
            return Ok(Value::Null);
        }
        serde_json::from_str(&reply.text).map_err(|error| Error::Json(error.to_string()))
    }

    fn key(&self) -> Result<VerifyingKey, Error> {
        let pem = match &self.public_key {
            Some(key) => key,
            None => return Err(Error::Offline("A public key is required".to_string())),
        };
        let body: String = pem.lines().filter(|line| !line.starts_with("-----")).map(str::trim).collect();
        let der = STANDARD.decode(body).map_err(|_| Error::Signature)?;
        if der.len() < 32 {
            return Err(Error::Signature);
        }
        let raw: [u8; 32] = der[der.len() - 32..].try_into().map_err(|_| Error::Signature)?;
        VerifyingKey::from_bytes(&raw).map_err(|_| Error::Signature)
    }

    fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), Error> {
        let signature = Signature::from_slice(signature).map_err(|_| Error::Signature)?;
        self.key()?.verify(message, &signature).map_err(|_| Error::Signature)
    }

    fn check<T: DeserializeOwned>(&self, path: &str, body: Value) -> Result<T, Error> {
        let reply = self.send("POST", path, &[], Some(body))?;
        if reply.status == 429 || reply.status >= 500 {
            return Err(Self::fail(&reply));
        }
        if self.public_key.is_some() {
            let signature = reply.signature.as_deref().ok_or(Error::Signature)?;
            self.verify(reply.text.as_bytes(), &STANDARD.decode(signature).map_err(|_| Error::Signature)?)?;
        }
        serde_json::from_str(&reply.text).map_err(|error| Error::Json(error.to_string()))
    }

    fn body(&self, key: &str, fields: &[(&str, &str)], product: bool) -> Value {
        let mut body: Map<String, Value> = fields.iter().map(|(name, value)| (name.to_string(), json!(value))).collect();
        body.insert("license_key".to_string(), json!(key));
        if let (true, Some(name), false) = (product, &self.product, body.contains_key("product_name")) {
            body.insert("product_name".to_string(), json!(name));
        }
        Value::Object(body)
    }

    pub fn validate(&self, key: &str, fields: &[(&str, &str)]) -> Result<Validation, Error> {
        self.check("/licenses/validate", self.body(key, fields, true))
    }

    pub fn activate(&self, key: &str, fields: &[(&str, &str)]) -> Result<Validation, Error> {
        self.check("/licenses/activate", self.body(key, fields, true))
    }

    pub fn deactivate(&self, key: &str, fields: &[(&str, &str)]) -> Result<Deactivation, Error> {
        self.check("/licenses/deactivate", self.body(key, fields, false))
    }

    pub fn file(&self, id: &str) -> Result<String, Error> {
        let reply = self.send("GET", &format!("/licenses/{id}/file"), &[], None)?;
        if reply.status >= 400 {
            return Err(Self::fail(&reply));
        }
        Ok(reply.text.trim().to_string())
    }

    fn date(license: &Value, field: &str) -> Result<Option<DateTime<Utc>>, Error> {
        match license.get(field).and_then(Value::as_str) {
            Some(text) => DateTime::parse_from_rfc3339(text).map(|date| Some(date.with_timezone(&Utc))).map_err(|error| Error::Offline(error.to_string())),
            None => Ok(None),
        }
    }

    pub fn offline(&self, file: &str) -> Result<Value, Error> {
        let parts: Vec<&str> = file.trim().split('.').collect();
        if parts.len() != 2 {
            return Err(Error::Offline("Offline license file is malformed".to_string()));
        }
        let payload = URL_SAFE_NO_PAD.decode(parts[0]).map_err(|_| Error::Offline("Offline license file is malformed".to_string()))?;
        let signature = URL_SAFE_NO_PAD.decode(parts[1]).map_err(|_| Error::Offline("Offline license file is malformed".to_string()))?;
        self.verify(&payload, &signature)?;
        let license: Value = serde_json::from_slice(&payload).map_err(|error| Error::Json(error.to_string()))?;
        let now = Utc::now();
        if matches!(Self::date(&license, "refresh_after")?, Some(date) if date < now) {
            return Err(Error::Offline("Offline license needs a refresh".to_string()));
        }
        if matches!(Self::date(&license, "expires_at")?, Some(date) if date < now) {
            return Err(Error::Offline("Offline license expired".to_string()));
        }
        Ok(license)
    }

    pub fn merge(&self, customer: &str, duplicate: &str) -> Result<Value, Error> {
        let data = self.call("POST", &format!("/customers/{customer}/merge"), &[], Some(json!({ "customer_id": duplicate })))?;
        Ok(data["customer"].clone())
    }
}

pub struct Resource<'a> {
    client: &'a Client,
    name: &'static str,
    single: &'static str,
}

impl Resource<'_> {
    pub fn list(&self, query: &[(&str, &str)]) -> Result<(i64, Vec<Value>), Error> {
        let data = self.client.call("GET", &format!("/{}", self.name), query, None)?;
        let rows = data[self.name].as_array().cloned().ok_or_else(|| Error::Json(format!("missing {}", self.name)))?;
        let total = data["total"].as_i64().ok_or_else(|| Error::Json("missing total".to_string()))?;
        Ok((total, rows))
    }

    pub fn get(&self, id: &str) -> Result<Value, Error> {
        self.client.call("GET", &format!("/{}/{}", self.name, id), &[], None)
    }

    pub fn create(&self, fields: Value) -> Result<Value, Error> {
        Ok(self.client.call("POST", &format!("/{}", self.name), &[], Some(fields))?[self.single].clone())
    }

    pub fn update(&self, id: &str, fields: Value) -> Result<Value, Error> {
        Ok(self.client.call("PATCH", &format!("/{}/{}", self.name, id), &[], Some(fields))?[self.single].clone())
    }

    pub fn delete(&self, id: &str, force: bool) -> Result<(), Error> {
        let query: &[(&str, &str)] = if force { &[("force", "true")] } else { &[] };
        self.client.call("DELETE", &format!("/{}/{}", self.name, id), query, None).map(|_| ())
    }
}