browsr-client 0.4.0

Client for driving Browsr browser automation over HTTP or stdout transports.
Documentation
//! Configuration for the Browsr client.
//!
//! The client can be configured via:
//! - Environment variables (`BROWSR_BASE_URL`, `BROWSR_API_KEY`)
//! - Programmatic configuration via `BrowsrClientConfig`
//!
//! # Environment Variables
//!
//! - `BROWSR_BASE_URL`: The base URL of the Browsr server (default: `https://api.browsr.dev`)
//! - `BROWSR_API_KEY`: Optional API key for authentication
//! - `BROWSR_BEARER_TOKEN`: Optional JWT bearer token for authentication
//! - `BROWSR_API_URL`: Legacy alias for `BROWSR_BASE_URL`
//! - `BROWSR_HOST` / `BROWSR_PORT`: Alternative way to specify local server
//!
//! # Example
//!
//! ```rust
//! use browsr_client::{BrowsrClient, BrowsrClientConfig};
//!
//! // From environment variables
//! let client = BrowsrClient::from_env();
//!
//! // From explicit config (cloud with API key)
//! let config = BrowsrClientConfig::new("https://api.browsr.dev")
//!     .with_api_key("your-api-key");
//! let client = BrowsrClient::from_config(config);
//! ```

use serde::{Deserialize, Serialize};

/// Default base URL for the Browsr cloud service
pub const DEFAULT_BASE_URL: &str = "https://api.browsr.dev";

/// Environment variable for the base URL
pub const ENV_BASE_URL: &str = "BROWSR_BASE_URL";

/// Environment variable for the API key
pub const ENV_API_KEY: &str = "BROWSR_API_KEY";

/// Environment variable for bearer-token authentication
pub const ENV_BEARER_TOKEN: &str = "BROWSR_BEARER_TOKEN";

/// Configuration for the Browsr client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowsrClientConfig {
    /// Base URL of the Browsr server
    pub base_url: String,

    /// Optional API key for authentication
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_key: Option<String>,

    /// Optional bearer token for authentication
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bearer_token: Option<String>,

    /// Request timeout in seconds (default: 60)
    #[serde(default = "default_timeout")]
    pub timeout_secs: u64,

    /// Number of retry attempts for failed requests (default: 3)
    #[serde(default = "default_retries")]
    pub retry_attempts: u32,

    /// Default headless mode for browser sessions
    #[serde(default)]
    pub headless: Option<bool>,
}

fn default_timeout() -> u64 {
    60
}

fn default_retries() -> u32 {
    3
}

impl Default for BrowsrClientConfig {
    fn default() -> Self {
        Self {
            base_url: DEFAULT_BASE_URL.to_string(),
            api_key: None,
            bearer_token: None,
            timeout_secs: default_timeout(),
            retry_attempts: default_retries(),
            headless: None,
        }
    }
}

impl BrowsrClientConfig {
    /// Create a new config with the specified base URL.
    pub fn new(base_url: impl Into<String>) -> Self {
        Self {
            base_url: base_url.into().trim_end_matches('/').to_string(),
            ..Default::default()
        }
    }

    /// Create a config from environment variables.
    ///
    /// - `BROWSR_BASE_URL` or `BROWSR_API_URL`: Base URL (defaults to `https://api.browsr.dev`)
    /// - `BROWSR_API_KEY`: Optional API key
    /// - `BROWSR_BEARER_TOKEN`: Optional JWT bearer token
    /// - `BROWSR_HOST` / `BROWSR_PORT`: Alternative local server specification
    pub fn from_env() -> Self {
        // Check for explicit base URL
        let base_url = std::env::var(ENV_BASE_URL)
            .ok()
            .unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
            .trim_end_matches('/')
            .to_string();

        let api_key = std::env::var(ENV_API_KEY).ok().filter(|s| !s.is_empty());
        let bearer_token = std::env::var(ENV_BEARER_TOKEN)
            .ok()
            .filter(|s| !s.is_empty());

        Self {
            base_url,
            api_key,
            bearer_token,
            ..Default::default()
        }
    }

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

    /// Set the bearer token for authentication.
    pub fn with_bearer_token(mut self, bearer_token: impl Into<String>) -> Self {
        self.bearer_token = Some(bearer_token.into());
        self
    }

    /// Set the request timeout in seconds.
    pub fn with_timeout(mut self, timeout_secs: u64) -> Self {
        self.timeout_secs = timeout_secs;
        self
    }

    /// Set the number of retry attempts.
    pub fn with_retries(mut self, retry_attempts: u32) -> Self {
        self.retry_attempts = retry_attempts;
        self
    }

    /// Set the default headless mode.
    pub fn with_headless(mut self, headless: bool) -> Self {
        self.headless = Some(headless);
        self
    }

    /// Check if the client is configured for local development (localhost).
    pub fn is_local(&self) -> bool {
        self.base_url.contains("localhost") || self.base_url.contains("127.0.0.1")
    }

    /// Check if authentication is configured.
    pub fn has_auth(&self) -> bool {
        self.api_key.is_some() || self.bearer_token.is_some()
    }

    /// Build a reqwest client with the configured settings.
    pub fn build_http_client(&self) -> Result<reqwest::Client, reqwest::Error> {
        let mut builder =
            reqwest::Client::builder().timeout(std::time::Duration::from_secs(self.timeout_secs));

        // Add default headers if API key or bearer token is configured
        let mut headers = reqwest::header::HeaderMap::new();
        if let Some(ref api_key) = self.api_key {
            headers.insert(
                "x-api-key",
                reqwest::header::HeaderValue::from_str(api_key).expect("Invalid API key format"),
            );
        }
        if let Some(ref bearer_token) = self.bearer_token {
            let value = format!("Bearer {}", bearer_token);
            headers.insert(
                reqwest::header::AUTHORIZATION,
                reqwest::header::HeaderValue::from_str(&value)
                    .expect("Invalid bearer token format"),
            );
        }
        if !headers.is_empty() {
            builder = builder.default_headers(headers);
        }

        builder.build()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_default_config() {
        let config = BrowsrClientConfig::default();
        assert_eq!(config.base_url, DEFAULT_BASE_URL);
        assert!(config.api_key.is_none());
        assert!(config.bearer_token.is_none());
        assert!(!config.is_local());
    }

    #[test]
    fn test_local_config() {
        let config = BrowsrClientConfig::new("http://localhost:8082");
        assert!(config.is_local());
        assert!(!config.has_auth());
    }

    #[test]
    fn test_with_api_key() {
        let config = BrowsrClientConfig::default().with_api_key("test-key");
        assert!(config.has_auth());
        assert_eq!(config.api_key, Some("test-key".to_string()));
    }

    #[test]
    fn test_with_bearer_token() {
        let config = BrowsrClientConfig::default().with_bearer_token("test-token");
        assert!(config.has_auth());
        assert_eq!(config.bearer_token, Some("test-token".to_string()));
    }

    #[test]
    fn test_trailing_slash_removed() {
        let config = BrowsrClientConfig::new("http://localhost:8082/");
        assert_eq!(config.base_url, "http://localhost:8082");
    }
}