reasoninglayer 1.0.3

Rust client SDK for the Reasoning Layer API
Documentation
//! Client configuration.
//!
//! The [`ClientConfig`] builder is the single entry point for configuring a
//! [`ReasoningLayerClient`](crate::ReasoningLayerClient). Required fields are
//! `base_url`, `tenant_id`, and `auth`; all others have sensible defaults:
//!
//! | Field | Default |
//! | --- | --- |
//! | `timeout` | 30 seconds |
//! | `max_retries` | 3 |
//! | `retry_on_503` | `false` |
//!
//! # Authentication
//!
//! The `auth` argument is **required and explicit** — there is no
//! unauthenticated mode. Pick one of:
//!
//! - [`AuthConfig::Bearer`] — for server-side / programmatic usage. The SDK
//!   sends `Authorization: Bearer <token>` on every request. Use this with a
//!   long-lived API token issued by the auth gateway (typically a
//!   service-account token).
//! - [`AuthConfig::Cookie`] — for callers that already hold a session cookie
//!   set by the auth gateway after an interactive login. The SDK does not
//!   add any auth header; the cookie must be carried by the underlying HTTP
//!   stack (rare in pure Rust HTTP clients — primarily useful when the SDK
//!   is wrapped behind a UI layer that owns a cookie store).
//!
//! ```
//! use reasoninglayer::{AuthConfig, ClientConfig};
//!
//! let config = ClientConfig::new(
//!     "https://platform.ovh.reasoninglayer.ai",
//!     "00000000-0000-0000-0000-000000000001",
//!     AuthConfig::Bearer("eyJ…".to_string()),
//! );
//! assert_eq!(config.max_retries, 3);
//! ```

use std::time::Duration;

use crate::error::Error;

/// SDK version — sent as `X-SDK-Version` on every request.
pub const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");

/// Language identifier — sent as `X-SDK-Language` on every request.
pub const SDK_LANGUAGE: &str = "rust";

/// Default request timeout.
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);

/// Default maximum retry attempts.
pub const DEFAULT_MAX_RETRIES: u32 = 3;

/// Authentication mode for the SDK.
///
/// The choice is **required and explicit** — there is no implicit
/// unauthenticated mode. See the module-level docs for guidance on which
/// variant to pick.
#[derive(Debug, Clone)]
pub enum AuthConfig {
    /// Send `Authorization: Bearer <token>` on every request.
    ///
    /// Use this for server-side / programmatic usage with a long-lived API
    /// token (e.g. a service-account token issued by the auth gateway).
    Bearer(String),
    /// Rely on a session cookie carried by the underlying HTTP stack.
    ///
    /// The SDK does not add any auth header. Useful when the SDK is wrapped
    /// behind a layer that owns a cookie store.
    Cookie,
}

/// Configuration for a [`ReasoningLayerClient`](crate::ReasoningLayerClient).
///
/// Build with [`ClientConfig::new`] and set optional fields with the builder-style `with_*` methods.
/// All fields are `pub` so the struct can also be constructed directly if preferred.
#[derive(Debug, Clone)]
pub struct ClientConfig {
    /// Base URL of the Reasoning Layer API (e.g., `"https://platform.ovh.reasoninglayer.ai"`). Trailing slashes stripped.
    pub base_url: String,
    /// Tenant UUID — sent as `X-Tenant-Id`. Set once; never overridable per-call.
    pub tenant_id: String,
    /// Authentication mode. Required — pick one of [`AuthConfig`].
    pub auth: AuthConfig,
    /// Default user UUID for `X-User-Id`. Overridable per-call via [`RequestOptions`](crate::RequestOptions).
    pub user_id: Option<String>,
    /// Default namespace UUID for `X-Namespace-Id`.
    pub namespace_id: Option<String>,
    /// Authenticated user identifier for `X-Authenticated-User`.
    pub authenticated_user: Option<String>,
    /// Default request timeout.
    pub timeout: Duration,
    /// Maximum retry attempts on transient failures.
    pub max_retries: u32,
    /// Retry on HTTP 503 (Service Unavailable).
    pub retry_on_503: bool,
    /// Additional custom headers sent on every request.
    pub extra_headers: Vec<(String, String)>,
}

impl ClientConfig {
    /// Create a configuration with the three required fields.
    pub fn new(
        base_url: impl Into<String>,
        tenant_id: impl Into<String>,
        auth: AuthConfig,
    ) -> Self {
        Self {
            base_url: base_url.into(),
            tenant_id: tenant_id.into(),
            auth,
            user_id: None,
            namespace_id: None,
            authenticated_user: None,
            timeout: DEFAULT_TIMEOUT,
            max_retries: DEFAULT_MAX_RETRIES,
            retry_on_503: false,
            extra_headers: Vec::new(),
        }
    }

    /// Set the default user ID (`X-User-Id`).
    #[must_use]
    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
        self.user_id = Some(user_id.into());
        self
    }

    /// Set the default namespace ID (`X-Namespace-Id`).
    #[must_use]
    pub fn with_namespace_id(mut self, namespace_id: impl Into<String>) -> Self {
        self.namespace_id = Some(namespace_id.into());
        self
    }

    /// Set the authenticated user identifier (`X-Authenticated-User`).
    #[must_use]
    pub fn with_authenticated_user(mut self, authenticated_user: impl Into<String>) -> Self {
        self.authenticated_user = Some(authenticated_user.into());
        self
    }

    /// Override the default request timeout (default: 30 seconds).
    #[must_use]
    pub fn with_timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    /// Override the maximum retry attempts (default: 3).
    #[must_use]
    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
        self.max_retries = max_retries;
        self
    }

    /// Enable retry on HTTP 503 (default: `false`).
    #[must_use]
    pub fn with_retry_on_503(mut self, retry_on_503: bool) -> Self {
        self.retry_on_503 = retry_on_503;
        self
    }

    /// Add a custom header that will be sent on every request.
    #[must_use]
    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.extra_headers.push((name.into(), value.into()));
        self
    }

    pub(crate) fn resolve(self) -> Result<ResolvedConfig, Error> {
        if self.base_url.is_empty() {
            return Err(Error::validation("base_url", "base_url is required"));
        }
        if self.tenant_id.is_empty() {
            return Err(Error::validation("tenant_id", "tenant_id is required"));
        }
        if let AuthConfig::Bearer(token) = &self.auth {
            if token.is_empty() {
                return Err(Error::validation(
                    "auth",
                    "AuthConfig::Bearer requires a non-empty token",
                ));
            }
        }
        let base_url = self.base_url.trim_end_matches('/').to_string();
        Ok(ResolvedConfig {
            base_url,
            tenant_id: self.tenant_id,
            auth: self.auth,
            user_id: self.user_id,
            namespace_id: self.namespace_id,
            authenticated_user: self.authenticated_user,
            timeout: self.timeout,
            max_retries: self.max_retries,
            retry_on_503: self.retry_on_503,
            extra_headers: self.extra_headers,
        })
    }
}

/// A fully resolved configuration with defaults applied.
#[derive(Debug, Clone)]
pub(crate) struct ResolvedConfig {
    pub base_url: String,
    pub tenant_id: String,
    pub auth: AuthConfig,
    pub user_id: Option<String>,
    pub namespace_id: Option<String>,
    pub authenticated_user: Option<String>,
    pub timeout: Duration,
    pub max_retries: u32,
    pub retry_on_503: bool,
    pub extra_headers: Vec<(String, String)>,
}