rs-puff 0.1.0

A modern (unofficial) Rust client for Turbopuffer
Documentation
use crate::{Error, Namespace, NamespacesResponse, Result};

const DEFAULT_BASE_URL: &str = "https://api.turbopuffer.com";

#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct NamespacesParams {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prefix: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub cursor: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub page_size: Option<u32>,
}

pub struct Client {
    pub(crate) api_key: String,
    pub(crate) base_url: String,
    pub(crate) http: reqwest::Client,
}

impl Client {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self {
            api_key: api_key.into(),
            base_url: DEFAULT_BASE_URL.to_string(),
            http: reqwest::Client::new(),
        }
    }

    pub fn with_region(api_key: impl Into<String>, region: &str) -> Self {
        let base_url = format!("https://{}.turbopuffer.com", region);
        Self {
            api_key: api_key.into(),
            base_url,
            http: reqwest::Client::new(),
        }
    }

    pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
        Self {
            api_key: api_key.into(),
            base_url: base_url.into(),
            http: reqwest::Client::new(),
        }
    }

    pub fn from_env() -> Result<Self> {
        let api_key = std::env::var("TURBOPUFFER_API_KEY")
            .map_err(|_| Error::Api {
                status: 0,
                message: "TURBOPUFFER_API_KEY not set".to_string(),
            })?;

        let base_url = std::env::var("TURBOPUFFER_REGION")
            .map(|r| format!("https://{}.turbopuffer.com", r))
            .unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());

        Ok(Self {
            api_key,
            base_url,
            http: reqwest::Client::new(),
        })
    }

    pub fn namespace(&self, name: impl Into<String>) -> Namespace<'_> {
        Namespace::new(self, name.into())
    }

    pub async fn namespaces(&self, params: NamespacesParams) -> Result<NamespacesResponse> {
        let mut query_parts = Vec::new();
        if let Some(ref prefix) = params.prefix {
            query_parts.push(format!("prefix={}", prefix));
        }
        if let Some(ref cursor) = params.cursor {
            query_parts.push(format!("cursor={}", cursor));
        }
        if let Some(page_size) = params.page_size {
            query_parts.push(format!("page_size={}", page_size));
        }

        let path = if query_parts.is_empty() {
            "/v1/namespaces".to_string()
        } else {
            format!("/v1/namespaces?{}", query_parts.join("&"))
        };

        self.request_no_body(reqwest::Method::GET, &path).await
    }

    pub(crate) async fn request<T, R>(&self, method: reqwest::Method, path: &str, body: Option<&T>) -> Result<R>
    where
        T: serde::Serialize + ?Sized,
        R: serde::de::DeserializeOwned,
    {
        let url = format!("{}{}", self.base_url, path);

        let mut req = self.http
            .request(method, &url)
            .header("Authorization", format!("Bearer {}", self.api_key))
            .header("Content-Type", "application/json");

        if let Some(body) = body {
            req = req.json(body);
        }

        let resp = req.send().await?;
        let status = resp.status();

        if !status.is_success() {
            let message = resp.text().await.unwrap_or_default();
            return Err(Error::Api {
                status: status.as_u16(),
                message,
            });
        }

        let result = resp.json().await?;
        Ok(result)
    }

    pub(crate) async fn request_no_body<R>(&self, method: reqwest::Method, path: &str) -> Result<R>
    where
        R: serde::de::DeserializeOwned,
    {
        self.request::<(), R>(method, path, None).await
    }
}