videosdk-server-sdk 0.1.0

Rust server SDK for the VideoSDK v2 REST APIs
Documentation
//! Client construction.

use std::sync::{Arc, Mutex};
use std::time::Duration;

use reqwest::header::{HeaderMap, HeaderName, HeaderValue};

use crate::client::{
    default_backoff, BackoffFn, Client, ClientInner, Overrides, TokenCache, DEFAULT_BASE_URL,
};
use crate::error::{Error, Result};
use crate::token::decode_token;

pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) const DEFAULT_MAX_RETRIES: u32 = 2;

/// Overrides applied to every token the client mints for its own requests.
#[derive(Debug, Clone, Default)]
pub struct TokenOptions {
    /// Permission claims. Defaults to `["allow_join", "allow_mod"]`.
    pub permissions: Option<Vec<String>>,
    /// Role claims. Defaults to `["crawler"]`.
    pub roles: Option<Vec<String>>,
    /// The token version. Defaults to `2`.
    pub version: Option<i64>,
    /// The token lifetime. Defaults to 24 hours.
    pub expires_in: Option<Duration>,
}

/// The resolved client configuration.
#[derive(Clone)]
pub(crate) struct Config {
    pub(crate) api_key: Option<String>,
    pub(crate) secret: Option<String>,
    pub(crate) max_retries: u32,
    /// A zero duration disables the per-request timeout.
    pub(crate) timeout: Duration,
    pub(crate) headers: HeaderMap,
    pub(crate) token_options: TokenOptions,
}

/// Builds a [`Client`].
///
/// Requires either a static token, or both an API key and a secret. Credentials
/// fall back to the `VIDEOSDK_API_KEY` and `VIDEOSDK_SECRET` environment
/// variables, and the endpoint to `VIDEOSDK_API_ENDPOINT`.
///
/// ```no_run
/// # fn main() -> Result<(), videosdk::Error> {
/// let client = videosdk::Client::builder()
///     .api_key("my-key")
///     .secret("my-secret")
///     .max_retries(3)
///     .build()?;
/// # Ok(())
/// # }
/// ```
#[derive(Default)]
pub struct ClientBuilder {
    api_key: Option<String>,
    secret: Option<String>,
    base_url: Option<String>,
    token: Option<String>,
    http_client: Option<reqwest::Client>,
    max_retries: Option<u32>,
    timeout: Option<Duration>,
    headers: Vec<(String, String)>,
    token_options: TokenOptions,
    backoff: Option<BackoffFn>,
}

impl ClientBuilder {
    /// Starts a new builder.
    pub fn new() -> Self {
        Self::default()
    }

    /// Sets the project API key. Falls back to `VIDEOSDK_API_KEY`.
    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
        self.api_key = Some(api_key.into());
        self
    }

    /// Sets the project secret. Falls back to `VIDEOSDK_SECRET`.
    pub fn secret(mut self, secret: impl Into<String>) -> Self {
        self.secret = Some(secret.into());
        self
    }

    /// Sets the API endpoint. Falls back to `VIDEOSDK_API_ENDPOINT`, then to
    /// [`DEFAULT_BASE_URL`].
    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
        self.base_url = Some(base_url.into());
        self
    }

    /// Uses a pre-generated static token. Disables automatic token refresh
    /// unless an API key and secret are also supplied.
    pub fn token(mut self, token: impl Into<String>) -> Self {
        self.token = Some(token.into());
        self
    }

    /// Supplies a preconfigured [`reqwest::Client`], e.g. with a proxy or a
    /// custom connection pool.
    pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
        self.http_client = Some(http_client);
        self
    }

    /// Sets how many times a failed request is retried. Defaults to 2, giving
    /// up to 3 total attempts.
    pub fn max_retries(mut self, max_retries: u32) -> Self {
        self.max_retries = Some(max_retries);
        self
    }

    /// Sets the per-request timeout. Defaults to 30 seconds; [`Duration::ZERO`]
    /// disables it.
    ///
    /// This applies to each attempt individually, so a request that exhausts its
    /// retries can take longer than this in total.
    pub fn request_timeout(mut self, timeout: Duration) -> Self {
        self.timeout = Some(timeout);
        self
    }

    /// Adds a header sent with every request.
    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.headers.push((name.into(), value.into()));
        self
    }

    /// Overrides the claims of the tokens the client mints for its own requests.
    pub fn token_options(mut self, token_options: TokenOptions) -> Self {
        self.token_options = token_options;
        self
    }

    /// Replaces the retry backoff.
    ///
    /// The default is exponential — `min(2^attempt seconds, 8s)` plus up to
    /// 249 ms of jitter — where `attempt` is the zero-based retry counter.
    pub fn backoff(mut self, backoff: impl Fn(u32) -> Duration + Send + Sync + 'static) -> Self {
        self.backoff = Some(Arc::new(backoff));
        self
    }

    /// Builds the client, resolving credentials from the environment where they
    /// were not set explicitly.
    pub fn build(self) -> Result<Client> {
        let api_key = self.api_key.or_else(|| env_var("VIDEOSDK_API_KEY"));
        let secret = self.secret.or_else(|| env_var("VIDEOSDK_SECRET"));
        let base_url = self
            .base_url
            .or_else(|| env_var("VIDEOSDK_API_ENDPOINT"))
            .unwrap_or_else(|| DEFAULT_BASE_URL.to_string());

        if self.token.is_none() && (api_key.is_none() || secret.is_none()) {
            return Err(Error::config(
                "Client::builder() requires either a token, or both an API key and secret \
                 (via .api_key()/.secret(), or the VIDEOSDK_API_KEY / VIDEOSDK_SECRET env vars)",
            ));
        }

        let mut headers = HeaderMap::new();
        for (name, value) in self.headers {
            let name = HeaderName::try_from(name.as_str())
                .map_err(|_| Error::config(format!("invalid header name: {name:?}")))?;
            let value = HeaderValue::try_from(value.as_str())
                .map_err(|_| Error::config(format!("invalid value for header {name:?}")))?;
            headers.insert(name, value);
        }

        let http = match self.http_client {
            Some(http) => http,
            None => reqwest::Client::builder()
                .build()
                .map_err(|e| Error::config(format!("failed to build the HTTP client: {e}")))?,
        };

        let can_refresh = api_key.is_some() && secret.is_some();

        // A static token seeds the cache; decoding its `exp` is best-effort.
        let mut cache = TokenCache::default();
        if let Some(token) = self.token {
            cache.expires_at = decode_token(&token).ok().and_then(|c| c.expires_at);
            cache.token = Some(token);
        }

        let config = Config {
            api_key,
            secret,
            max_retries: self.max_retries.unwrap_or(DEFAULT_MAX_RETRIES),
            timeout: self.timeout.unwrap_or(DEFAULT_TIMEOUT),
            headers,
            token_options: self.token_options,
        };

        Ok(Client::from_parts(
            Arc::new(ClientInner {
                config,
                http,
                base_url: base_url.trim_end_matches('/').to_string(),
                can_refresh,
                token: Mutex::new(cache),
                backoff: self.backoff.unwrap_or_else(|| Arc::new(default_backoff)),
            }),
            Overrides::default(),
        ))
    }
}

fn env_var(name: &str) -> Option<String> {
    std::env::var(name).ok().filter(|v| !v.is_empty())
}