invoance 0.2.0

Official Rust SDK for the Invoance compliance API
Documentation
//! Top-level SDK client.

use std::sync::Arc;

use crate::config::{resolve_config, ClientConfig};
use crate::error::Error;
use crate::http::HttpTransport;
use crate::resources::{
    AttestationsResource, AuditResource, DocumentsResource, EventsResource, TracesResource,
};

/// Outcome of [`InvoanceClient::validate`].
///
/// `valid == true` means the API key was accepted by the server (2xx, 403, or
/// 429 — 403 and 429 still prove the key authenticated). `valid == false` means
/// the key was rejected, or the request never reached the server.
#[derive(Debug, Clone)]
pub struct ValidationResult {
    pub valid: bool,
    pub reason: Option<String>,
    pub base_url: String,
}

/// The top-level Invoance API client.
///
/// ```no_run
/// # async fn run() -> Result<(), invoance::Error> {
/// use invoance::InvoanceClient;
///
/// // Reads INVOANCE_API_KEY / INVOANCE_BASE_URL from the environment.
/// let client = InvoanceClient::new()?;
///
/// let listed = client.events().list(Default::default()).await?;
/// println!("{} events", listed.total);
/// # Ok(()) }
/// ```
#[derive(Clone)]
pub struct InvoanceClient {
    transport: Arc<HttpTransport>,
    base_url: String,
}

impl InvoanceClient {
    /// Construct a client using environment variables (`INVOANCE_API_KEY`,
    /// `INVOANCE_BASE_URL`).
    pub fn new() -> Result<Self, Error> {
        Self::from_config(ClientConfig::default())
    }

    /// Construct a client from an explicit [`ClientConfig`], falling back to
    /// environment variables and defaults for any unset field.
    pub fn from_config(config: ClientConfig) -> Result<Self, Error> {
        let resolved = resolve_config(config)?;
        let base_url = resolved.base_url.clone();
        let transport = Arc::new(HttpTransport::new(resolved)?);
        Ok(Self {
            transport,
            base_url,
        })
    }

    /// Start building a client fluently. See [`ClientBuilder`].
    pub fn builder() -> ClientBuilder {
        ClientBuilder::default()
    }

    /// The `/events` resource handle.
    pub fn events(&self) -> EventsResource {
        EventsResource::new(self.transport.clone())
    }

    /// The `/document` resource handle.
    pub fn documents(&self) -> DocumentsResource {
        DocumentsResource::new(self.transport.clone())
    }

    /// The `/ai/attestations` resource handle.
    pub fn attestations(&self) -> AttestationsResource {
        AttestationsResource::new(self.transport.clone())
    }

    /// The `/traces` resource handle.
    pub fn traces(&self) -> TracesResource {
        TracesResource::new(self.transport.clone())
    }

    /// The `/audit` resource namespace.
    pub fn audit(&self) -> AuditResource {
        AuditResource::new(self.transport.clone())
    }

    /// Fetch the key introspection document (`GET /v1/me`) — the organization,
    /// tenant, API-key metadata (scopes, prefix, last4), and rate limits
    /// behind the current key. Requires no scopes. Returns the raw decoded
    /// JSON body.
    pub async fn me(&self) -> Result<serde_json::Value, Error> {
        self.transport.get_raw("/me").await
    }

    /// Probe the introspection endpoint (`GET /v1/me`) to confirm the API key
    /// works. The endpoint requires no scopes, so a key restricted to a subset
    /// of scopes (e.g. `audit:*` only) still validates. Never returns `Err`
    /// for the probe itself — every failure mode is folded into the
    /// [`ValidationResult`].
    pub async fn validate(&self) -> ValidationResult {
        let base_url = self.base_url.clone();
        match self.me().await {
            Ok(_) => ValidationResult {
                valid: true,
                reason: None,
                base_url,
            },
            Err(e) if e.is_authentication() => ValidationResult {
                valid: false,
                reason: Some("Authentication failed — check INVOANCE_API_KEY".into()),
                base_url,
            },
            Err(e) if e.is_forbidden() => ValidationResult {
                valid: true,
                reason: Some(
                    "API key authenticated but the request was blocked (IP access rules)".into(),
                ),
                base_url,
            },
            Err(e) if e.is_quota_exceeded() => ValidationResult {
                valid: true,
                reason: Some("API key authenticated but currently rate limited".into()),
                base_url,
            },
            Err(e) if e.is_network() || e.is_timeout() => ValidationResult {
                valid: false,
                reason: Some(format!("Server unreachable: {e}")),
                base_url,
            },
            Err(e) => ValidationResult {
                valid: false,
                reason: Some(e.to_string()),
                base_url,
            },
        }
    }
}

/// Fluent builder for [`InvoanceClient`].
#[derive(Debug, Clone, Default)]
pub struct ClientBuilder {
    config: ClientConfig,
}

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

    /// Override the base URL.
    pub fn base_url(mut self, url: impl Into<String>) -> Self {
        self.config.base_url = Some(url.into());
        self
    }

    /// Set the API version prefix (default `v1`).
    pub fn api_version(mut self, v: impl Into<String>) -> Self {
        self.config.api_version = Some(v.into());
        self
    }

    /// Set the request timeout.
    pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
        self.config.timeout = Some(timeout);
        self
    }

    /// Set a default idempotency key sent with every mutating request.
    pub fn idempotency_key(mut self, key: impl Into<String>) -> Self {
        self.config.idempotency_key = Some(key.into());
        self
    }

    /// Add an extra header merged into every request.
    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.config.extra_headers.insert(name.into(), value.into());
        self
    }

    /// Finish building.
    pub fn build(self) -> Result<InvoanceClient, Error> {
        InvoanceClient::from_config(self.config)
    }
}