sdk-rust 0.1.0

Canonical Rust core for the Lattix metadata-only control-plane SDK
Documentation
use std::{collections::BTreeMap, time::Duration};

use crate::{
    client::{Client, ClientAuthStrategy, SdkClientCredentialsAuth},
    error::SdkError,
};

pub struct ClientBuilder {
    base_url: String,
    bearer_token: Option<String>,
    client_id: Option<String>,
    client_secret: Option<String>,
    tenant_id: Option<String>,
    user_id: Option<String>,
    timeout_secs: Option<u64>,
    token_exchange_path: Option<String>,
    requested_scopes: Vec<String>,
    headers: BTreeMap<String, String>,
}

impl ClientBuilder {
    pub fn new(base_url: impl Into<String>) -> Self {
        Self {
            base_url: base_url.into(),
            bearer_token: None,
            client_id: None,
            client_secret: None,
            tenant_id: None,
            user_id: None,
            timeout_secs: None,
            token_exchange_path: None,
            requested_scopes: Vec::new(),
            headers: BTreeMap::new(),
        }
    }

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

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

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

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

    pub fn with_requested_scopes<I, S>(mut self, scopes: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.requested_scopes = scopes
            .into_iter()
            .map(Into::into)
            .map(|scope| scope.trim().to_string())
            .filter(|scope| !scope.is_empty())
            .collect();
        self.requested_scopes.sort();
        self.requested_scopes.dedup();
        self
    }

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

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

    pub fn with_timeout_secs(mut self, timeout_secs: u64) -> Self {
        self.timeout_secs = Some(timeout_secs);
        self
    }

    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.headers.insert(name.into(), value.into());
        self
    }

    pub fn build(self) -> Result<Client, SdkError> {
        let base_url = self.base_url.trim().trim_end_matches('/').to_string();
        if base_url.is_empty() {
            return Err(SdkError::InvalidInput(
                "base_url cannot be empty".to_string(),
            ));
        }

        let mut headers = self.headers;
        let tenant_id = self.tenant_id.map(|tenant_id| tenant_id.trim().to_string());
        if let Some(tenant_id) = tenant_id.as_deref()
            && !tenant_id.is_empty()
        {
            headers.insert("x-lattix-tenant-id".to_string(), tenant_id.to_string());
        }
        if let Some(user_id) = self.user_id {
            let user_id = user_id.trim();
            if !user_id.is_empty() {
                headers.insert("x-lattix-user-id".to_string(), user_id.to_string());
            }
        }

        let mut agent_builder = ureq::AgentBuilder::new();
        if let Some(timeout_secs) = self.timeout_secs {
            agent_builder = agent_builder.timeout(Duration::from_secs(timeout_secs));
        }

        let auth_strategy = if let Some(bearer_token) = self.bearer_token {
            let bearer_token = bearer_token.trim().to_string();
            if bearer_token.is_empty() {
                None
            } else {
                Some(ClientAuthStrategy::StaticBearer(format!(
                    "Bearer {bearer_token}"
                )))
            }
        } else {
            match (self.client_id, self.client_secret) {
                (Some(client_id), Some(client_secret)) => {
                    let tenant_id = tenant_id.ok_or_else(|| {
                        SdkError::InvalidInput(
                            "tenant_id is required when sdk client credentials are configured"
                                .to_string(),
                        )
                    })?;
                    let client_id = client_id.trim().to_string();
                    let client_secret = client_secret.trim().to_string();
                    if client_id.is_empty() || client_secret.is_empty() {
                        return Err(SdkError::InvalidInput(
                            "client_id and client_secret cannot be empty".to_string(),
                        ));
                    }

                    Some(ClientAuthStrategy::SdkClientCredentials(Box::new(
                        SdkClientCredentialsAuth::new(
                            tenant_id,
                            client_id,
                            client_secret,
                            self.token_exchange_path
                                .unwrap_or_else(|| "/v1/sdk/session".to_string()),
                            self.requested_scopes,
                        ),
                    )))
                }
                (None, None) => None,
                _ => {
                    return Err(SdkError::InvalidInput(
                        "client_id and client_secret must be provided together".to_string(),
                    ));
                }
            }
        };

        Ok(Client::new(
            base_url,
            agent_builder.build(),
            headers,
            auth_strategy,
        ))
    }
}