cnb 0.2.1

CNB (cnb.cool) API client for Rust — typed, async, production-ready
Documentation
//! Top-level [`ApiClient`] facade and its [`ClientBuilder`].
//!
//! The client owns a single, shared `reqwest::Client` (with default headers
//! pre-loaded for `Authorization` and `Accept`) and constructs one instance of
//! every resource client on demand from the shared [`HttpInner`](crate::http::HttpInner).
//!
//! # Authentication
//!
//! By default, [`ClientBuilder::build`] reads the `CNB_TOKEN` environment
//! variable. An explicit [`ClientBuilder::token`] always wins. Call
//! [`ClientBuilder::no_token`] to construct an anonymous client for public
//! endpoints even when `CNB_TOKEN` is set in the environment.
//!
//! ```no_run
//! use cnb::ApiClient;
//!
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
//! // Picks up CNB_TOKEN automatically:
//! let client = ApiClient::new()?;
//!
//! // Or be explicit:
//! let client = ApiClient::builder()
//!     .token("your-pat-here")
//!     .build()?;
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! # }
//! ```

use std::{env, time::Duration};

use reqwest::{
    header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT},
    Client,
};
use url::Url;

use crate::{
    error::{ApiError, Result},
    http::HttpInner,
    retry::RetryConfig,
};

/// Default base URL — the production CNB endpoint.
pub const DEFAULT_BASE_URL: &str = "https://api.cnb.cool";

/// Wire format the CNB API uses for successful JSON responses. We pre-set this
/// in the shared `reqwest::Client`'s default headers so callers never need to
/// think about it.
pub const CNB_API_ACCEPT: &str = "application/vnd.cnb.api+json";

/// Environment variable consulted by [`ClientBuilder::build`] when no explicit
/// token is provided.
pub const TOKEN_ENV_VAR: &str = "CNB_TOKEN";

/// Default request timeout. Override with [`ClientBuilder::timeout`].
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);

/// Top-level entry point of the SDK.
#[derive(Debug, Clone)]
pub struct ApiClient {
    inner: HttpInner,
}

/// Private helper macro: emit a resource-accessor method on `ApiClient`. Must be
/// used at module scope *before* the `impl` block so the expansion body can
/// call `Self::new`-style constructors on the generated `*Client` types.
macro_rules! resource_accessor {
    (
        $(#[$m:meta])*
        $feature:literal,
        $name:ident,
        $module:ident,
        $client:ident
    ) => {
        $(#[$m])*
        #[cfg(feature = $feature)]
        #[cfg_attr(docsrs, doc(cfg(feature = $feature)))]
        pub fn $name(&self) -> crate::$module::$client {
            crate::$module::$client::new(self.inner.clone())
        }
    };
}

impl ApiClient {
    /// Construct a client using all defaults: production base URL, picking up
    /// `CNB_TOKEN` from the environment, the standard timeout, and the default
    /// retry policy.
    pub fn new() -> Result<Self> {
        ClientBuilder::new().build()
    }

    /// Get a [`ClientBuilder`] for fine-grained configuration.
    pub fn builder() -> ClientBuilder {
        ClientBuilder::new()
    }

    /// Borrow the underlying transport. Mostly useful for advanced users who
    /// want to make ad-hoc calls outside the generated resource modules.
    pub fn http(&self) -> &HttpInner {
        &self.inner
    }

    resource_accessor!(/// Activities API.
        "activities", activities, activities, ActivitiesClient);
    resource_accessor!(/// AI API.
        "ai", ai, ai, AiClient);
    resource_accessor!(/// Artifactory API.
        "artifactory", artifactory, artifactory, ArtifactoryClient);
    resource_accessor!(/// Assets API.
        "assets", assets, assets, AssetsClient);
    resource_accessor!(/// Badge API.
        "badge", badge, badge, BadgeClient);
    resource_accessor!(/// Build API.
        "build", build, build, BuildClient);
    resource_accessor!(/// Charge API.
        "charge", charge, charge, ChargeClient);
    resource_accessor!(/// Event API.
        "event", event, event, EventClient);
    resource_accessor!(/// Followers API.
        "followers", followers, followers, FollowersClient);
    resource_accessor!(/// Git API (branches, tags, commits, …).
        "git", git, git, GitClient);
    resource_accessor!(/// Git settings API.
        "git_settings", git_settings, git_settings, GitSettingsClient);
    resource_accessor!(/// Issues API.
        "issues", issues, issues, IssuesClient);
    resource_accessor!(/// Knowledge base API.
        "knowledge_base", knowledge_base, knowledge_base, KnowledgeBaseClient);
    resource_accessor!(/// Members API.
        "members", members, members, MembersClient);
    resource_accessor!(/// Missions API.
        "missions", missions, missions, MissionsClient);
    resource_accessor!(/// Organizations API.
        "organizations", organizations, organizations, OrganizationsClient);
    resource_accessor!(/// Pull requests API.
        "pulls", pulls, pulls, PullsClient);
    resource_accessor!(/// Public ranking API.
        "rank", rank, rank, RankClient);
    resource_accessor!(/// Registries API.
        "registries", registries, registries, RegistriesClient);
    resource_accessor!(/// Releases API.
        "releases", releases, releases, ReleasesClient);
    resource_accessor!(/// Repository code-issue API.
        "repo_code_issue", repo_code_issue, repo_code_issue, RepoCodeIssueClient);
    resource_accessor!(/// Repository contributor API.
        "repo_contributor", repo_contributor, repo_contributor, RepoContributorClient);
    resource_accessor!(/// Repository labels API.
        "repo_labels", repo_labels, repo_labels, RepoLabelsClient);
    resource_accessor!(/// Repositories API.
        "repositories", repositories, repositories, RepositoriesClient);
    resource_accessor!(/// Search API.
        "search", search, search, SearchClient);
    resource_accessor!(/// Security API.
        "security", security, security, SecurityClient);
    resource_accessor!(/// Starring API.
        "starring", starring, starring, StarringClient);
    resource_accessor!(/// Users API.
        "users", users, users, UsersClient);
    resource_accessor!(/// Wiki API.
        "wiki", wiki, wiki, WikiClient);
    resource_accessor!(/// Workspaces API.
        "workspace", workspace, workspace, WorkspaceClient);
}

/// Builder for [`ApiClient`]. See module docs for examples.
#[derive(Debug, Clone)]
pub struct ClientBuilder {
    base_url: String,
    token: TokenSource,
    timeout: Option<Duration>,
    user_agent: Option<String>,
    retry: RetryConfig,
    extra_headers: Option<HeaderMap>,
}

#[derive(Debug, Clone)]
enum TokenSource {
    /// Use the explicit string verbatim.
    Explicit(String),
    /// Fall back to `CNB_TOKEN` from the environment; treat absence as
    /// "no auth".
    Env,
    /// Skip authentication entirely, even if `CNB_TOKEN` is set.
    None,
}

impl Default for ClientBuilder {
    fn default() -> Self {
        Self::new()
    }
}

impl ClientBuilder {
    /// Start a fresh builder with library defaults.
    pub fn new() -> Self {
        Self {
            base_url: DEFAULT_BASE_URL.to_string(),
            token: TokenSource::Env,
            timeout: Some(DEFAULT_TIMEOUT),
            user_agent: None,
            retry: RetryConfig::default(),
            extra_headers: None,
        }
    }

    /// Override the base URL — primarily useful for testing against a mock
    /// server (e.g. `wiremock`).
    pub fn base_url(mut self, url: impl Into<String>) -> Self {
        self.base_url = url.into();
        self
    }

    /// Use the supplied bearer token verbatim. Takes precedence over the
    /// `CNB_TOKEN` environment variable.
    pub fn token(mut self, token: impl Into<String>) -> Self {
        self.token = TokenSource::Explicit(token.into());
        self
    }

    /// Disable the `CNB_TOKEN` env-var fallback and build an anonymous client.
    /// Useful for hitting truly public endpoints from a process that happens to
    /// have `CNB_TOKEN` set for unrelated reasons.
    pub fn no_token(mut self) -> Self {
        self.token = TokenSource::None;
        self
    }

    /// Override the request timeout. Pass `None` to disable timeouts (rarely a
    /// good idea).
    pub fn timeout(mut self, timeout: impl Into<Option<Duration>>) -> Self {
        self.timeout = timeout.into();
        self
    }

    /// Override the `User-Agent` header. Default is `cnb-rs/<crate version>`.
    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
        self.user_agent = Some(ua.into());
        self
    }

    /// Replace the retry policy. See [`RetryConfig`].
    pub fn retry(mut self, retry: RetryConfig) -> Self {
        self.retry = retry;
        self
    }

    /// Add an arbitrary default header. Repeated calls accumulate. Silently
    /// ignores malformed names/values — if you need strict validation, build
    /// the `HeaderMap` yourself and install it via `reqwest::Client::builder`.
    pub fn default_header(mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> Self {
        let headers = self.extra_headers.get_or_insert_with(HeaderMap::new);
        if let (Ok(name), Ok(value)) = (
            reqwest::header::HeaderName::from_bytes(name.as_ref().as_bytes()),
            HeaderValue::from_str(value.as_ref()),
        ) {
            headers.insert(name, value);
        }
        self
    }

    /// Finalise the builder.
    pub fn build(self) -> Result<ApiClient> {
        // Resolve the token according to precedence: explicit > env > none.
        let token: Option<String> = match self.token {
            TokenSource::Explicit(t) if !t.is_empty() => Some(t),
            TokenSource::Explicit(_) => None,
            TokenSource::None => None,
            TokenSource::Env => match env::var(TOKEN_ENV_VAR) {
                Ok(v) if !v.is_empty() => Some(v),
                Ok(_) => None,
                Err(env::VarError::NotPresent) => None,
                Err(env::VarError::NotUnicode(_)) => {
                    return Err(ApiError::EnvVar(format!(
                        "{TOKEN_ENV_VAR} is not valid UTF-8"
                    )));
                }
            },
        };

        let mut headers = HeaderMap::new();
        // Pin the response media type so the server returns CNB's vendored
        // JSON and not, say, an HTML error page.
        headers.insert(ACCEPT, HeaderValue::from_static(CNB_API_ACCEPT));

        if let Some(token) = token {
            let bearer = format!("Bearer {token}");
            let mut hv = HeaderValue::from_str(&bearer)
                .map_err(|_| ApiError::EnvVar("invalid characters in CNB token".into()))?;
            // Marks the header as sensitive for any tracing/logging consumers.
            hv.set_sensitive(true);
            headers.insert(AUTHORIZATION, hv);
        }

        let ua = self
            .user_agent
            .unwrap_or_else(|| format!("cnb-rs/{}", env!("CARGO_PKG_VERSION")));
        headers.insert(
            USER_AGENT,
            HeaderValue::from_str(&ua)
                .map_err(|_| ApiError::EnvVar("invalid characters in user agent".into()))?,
        );

        if let Some(extras) = self.extra_headers {
            headers.extend(extras);
        }

        let mut builder = Client::builder().default_headers(headers);
        if let Some(t) = self.timeout {
            builder = builder.timeout(t);
        }
        let client = builder.build().map_err(ApiError::from)?;

        // Normalise the base URL: ensure it ends with `/` so `Url::join` keeps
        // every path segment we care about.
        let raw = if self.base_url.ends_with('/') {
            self.base_url.clone()
        } else {
            format!("{}/", self.base_url)
        };
        let base_url = Url::parse(&raw).map_err(ApiError::from)?;

        Ok(ApiClient {
            inner: HttpInner::new(base_url, client, self.retry),
        })
    }
}