gov-uk-sdk-core 0.1.0

Shared HTTP client, auth, errors, and content negotiation for GOV.UK / Companies House SDK crates.
Documentation
use std::sync::Arc;
use std::time::Duration;

use reqwest::{Client, Method};
use url::Url;

use crate::rate_limit::WindowRateLimiter;
use crate::request::SdkRequest;

/// Default base URL for the Companies House REST API.
pub const COMPANIES_HOUSE_API_ROOT: &str = "https://api.company-information.service.gov.uk";

/// Credentials applied to every request by [`SdkClient`].
#[derive(Debug, Clone)]
pub enum Auth {
    /// HTTP Basic: username = API key, password empty (Companies House public API).
    ApiKey { key: String },
    /// `Authorization: Bearer` (OAuth flows, filing, etc.).
    Bearer { token: String },
}

/// Build a configured [`SdkClient`].
#[derive(Debug, Clone)]
pub struct SdkClientBuilder {
    auth: Auth,
    base_url: Url,
    timeout: Duration,
    enable_ch_rate_limit: bool,
    user_agent: Option<String>,
}

#[derive(Debug, thiserror::Error)]
pub enum SdkBuildError {
    #[error(transparent)]
    HttpClient(#[from] reqwest::Error),
    #[error(transparent)]
    Url(#[from] url::ParseError),
    #[error("API key must be non-empty")]
    EmptyApiKey,
    #[error("bearer token must be non-empty")]
    EmptyBearer,
}

/// HTTP client with shared auth, optional CH rate limit, and base URL.
#[derive(Clone)]
pub struct SdkClient {
    pub(crate) inner: Arc<SdkClientInner>,
}

pub(crate) struct SdkClientInner {
    pub http: Client,
    pub base_url: Url,
    pub auth: Auth,
    pub limiter: Option<Arc<WindowRateLimiter>>,
}

impl SdkClient {
    /// Start from [`Auth`]; defaults to Companies House public API base URL.
    pub fn builder(auth: Auth) -> SdkClientBuilder {
        SdkClientBuilder::new(auth)
    }

    /// Starts a request with an arbitrary HTTP [`Method`] and path relative to the client base URL.
    pub fn request(
        &self,
        method: Method,
        path: impl AsRef<str>,
    ) -> crate::SdkResult<SdkRequest<'_>> {
        SdkRequest::new(self, method, path)
    }

    /// `GET` request builder for `path` relative to the base URL.
    pub fn get(&self, path: impl AsRef<str>) -> crate::SdkResult<SdkRequest<'_>> {
        self.request(Method::GET, path)
    }

    /// `POST` request builder for `path` relative to the base URL.
    pub fn post(&self, path: impl AsRef<str>) -> crate::SdkResult<SdkRequest<'_>> {
        self.request(Method::POST, path)
    }

    /// `PUT` request builder for `path` relative to the base URL.
    pub fn put(&self, path: impl AsRef<str>) -> crate::SdkResult<SdkRequest<'_>> {
        self.request(Method::PUT, path)
    }

    /// `DELETE` request builder for `path` relative to the base URL.
    pub fn delete(&self, path: impl AsRef<str>) -> crate::SdkResult<SdkRequest<'_>> {
        self.request(Method::DELETE, path)
    }

    /// `PATCH` request builder for `path` relative to the base URL.
    pub fn patch(&self, path: impl AsRef<str>) -> crate::SdkResult<SdkRequest<'_>> {
        self.request(Method::PATCH, path)
    }
}

impl SdkClientBuilder {
    /// Starts a builder using [`COMPANIES_HOUSE_API_ROOT`], 30s timeout, and rate limiting enabled.
    pub fn new(auth: Auth) -> Self {
        let base_url =
            Url::parse(COMPANIES_HOUSE_API_ROOT).expect("COMPANIES_HOUSE_API_ROOT is valid");
        Self {
            auth,
            base_url,
            timeout: Duration::from_secs(30),
            enable_ch_rate_limit: true,
            user_agent: None,
        }
    }

    /// Sets the API host root (trailing slash normalised when building URLs).
    pub fn base_url(mut self, url: Url) -> Self {
        self.base_url = url;
        self
    }

    /// Per-request timeout for the underlying HTTP client.
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    /// When `true` (default), applies a client-side **600 requests / 5 minutes** window
    /// before each HTTP call to align with Companies House application rate limits.
    pub fn enable_companies_house_rate_limit(mut self, enable: bool) -> Self {
        self.enable_ch_rate_limit = enable;
        self
    }

    /// Sets the `User-Agent` header on all requests.
    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
        self.user_agent = Some(ua.into());
        self
    }

    /// Validates credentials, normalises base URL, builds [`reqwest::Client`], and returns [`SdkClient`].
    pub fn build(self) -> Result<SdkClient, SdkBuildError> {
        match &self.auth {
            Auth::ApiKey { key } if key.is_empty() => return Err(SdkBuildError::EmptyApiKey),
            Auth::Bearer { token } if token.is_empty() => return Err(SdkBuildError::EmptyBearer),
            _ => {}
        }

        let mut base = self.base_url;
        let path = base.path();
        if path.is_empty() || path == "/" {
            base.set_path("/");
        } else if !path.ends_with('/') {
            base.set_path(&format!("{path}/"));
        }

        let mut client_builder = Client::builder().timeout(self.timeout);
        if let Some(ua) = self.user_agent {
            client_builder = client_builder.user_agent(ua);
        }
        let http = client_builder.build()?;

        let limiter = self
            .enable_ch_rate_limit
            .then(|| Arc::new(WindowRateLimiter::companies_house_default()));

        Ok(SdkClient {
            inner: Arc::new(SdkClientInner {
                http,
                base_url: base,
                auth: self.auth,
                limiter,
            }),
        })
    }
}