bindist 0.1.0

Rust client library for the BinDist Customer API
Documentation
use std::time::Duration;

use bytes::Bytes;
use reqwest::{header::CONTENT_TYPE, Method, StatusCode, Url};
use serde::{de::DeserializeOwned, Serialize};

use crate::error::{ApiError, Error, Result};
use crate::types::{
    Application, ApplicationsData, Channel, CreateShareLinkRequest, DownloadInfo, Envelope,
    FilesData, GetDownloadInfoOptions, ListApplicationsOptions, ListVersionsOptions, Meta,
    Pagination, ShareLink, Version, VersionFile, VersionsData,
};

const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const HEADER_X_CHANNEL: &str = "X-Channel";

/// A paginated slice of items plus the response metadata.
#[derive(Debug, Clone)]
pub struct Page<T> {
    pub items: T,
    pub pagination: Option<Pagination>,
    pub request_id: Option<String>,
}

/// Client for the `BinDist` Customer API.
#[derive(Debug, Clone)]
pub struct Client {
    base_url: Url,
    api_key: String,
    http: reqwest::Client,
}

impl Client {
    /// Create a new client with the default [`reqwest::Client`] and a 30s
    /// timeout.
    pub fn new(base_url: impl AsRef<str>, api_key: impl Into<String>) -> Result<Self> {
        let http = reqwest::Client::builder()
            .timeout(DEFAULT_TIMEOUT)
            .build()
            .map_err(Error::Http)?;
        Self::with_http_client(base_url, api_key, http)
    }

    /// Create a new client with a caller-supplied [`reqwest::Client`].
    pub fn with_http_client(
        base_url: impl AsRef<str>,
        api_key: impl Into<String>,
        http: reqwest::Client,
    ) -> Result<Self> {
        let trimmed = base_url.as_ref().trim_end_matches('/');
        let base_url = Url::parse(&format!("{trimmed}/"))
            .map_err(|e| Error::InvalidUrl(e.to_string()))?;
        Ok(Self {
            base_url,
            api_key: api_key.into(),
            http,
        })
    }

    /// The configured API base URL (always ends in `/`).
    pub fn base_url(&self) -> &Url {
        &self.base_url
    }

    /// Build a URL by extending the base with the given path segments.
    /// Each segment is percent-encoded by [`reqwest::Url`].
    fn url(&self, segments: &[&str]) -> Result<Url> {
        let mut url = self.base_url.clone();
        url.path_segments_mut()
            .map_err(|()| Error::InvalidUrl("base url cannot be a base".to_owned()))?
            .extend(segments);
        Ok(url)
    }

    async fn send<B: Serialize + ?Sized>(
        &self,
        method: Method,
        url: Url,
        channel: Option<Channel>,
        body: Option<&B>,
    ) -> Result<(StatusCode, Bytes)> {
        let mut req = self
            .http
            .request(method, url)
            .bearer_auth(&self.api_key)
            .header(CONTENT_TYPE, "application/json");
        if let Some(ch) = channel {
            req = req.header(HEADER_X_CHANNEL, ch.as_str());
        }
        if let Some(body) = body {
            req = req.json(body);
        }

        let resp = req.send().await?;
        let status = resp.status();
        let bytes = resp.bytes().await?;
        Ok((status, bytes))
    }

    async fn request<T, B>(
        &self,
        method: Method,
        url: Url,
        channel: Option<Channel>,
        body: Option<&B>,
    ) -> Result<(T, Option<Meta>)>
    where
        T: DeserializeOwned,
        B: Serialize + ?Sized,
    {
        let (status, bytes) = self.send(method, url, channel, body).await?;

        // Attempt to parse the envelope regardless of status so we can
        // surface a structured `error` field when the API sends one.
        let parsed: std::result::Result<Envelope<T>, serde_json::Error> =
            serde_json::from_slice(&bytes);

        if !status.is_success() {
            let err = match parsed {
                Ok(env) => env
                    .error
                    .unwrap_or_else(|| synthesize_error(status, &bytes, None))
                    .with_http_status(status.as_u16()),
                Err(decode_err) => synthesize_error(status, &bytes, Some(decode_err)),
            };
            return Err(Error::Api(err));
        }

        let env = parsed?;
        if let Some(err) = env.error {
            return Err(Error::Api(err.with_http_status(status.as_u16())));
        }
        let data = env.data.ok_or(Error::MissingData)?;
        Ok((data, env.meta))
    }

    // -- Applications -------------------------------------------------------

    /// `GET /v1/applications`
    pub async fn list_applications(
        &self,
        opts: &ListApplicationsOptions,
    ) -> Result<Page<Vec<Application>>> {
        let mut url = self.url(&["v1", "applications"])?;
        {
            let mut q = url.query_pairs_mut();
            if let Some(p) = opts.page {
                q.append_pair("page", &p.to_string());
            }
            if let Some(l) = opts.limit {
                q.append_pair("limit", &l.to_string());
            }
            if let Some(s) = opts.search.as_deref() {
                q.append_pair("search", s);
            }
            if let Some(t) = opts.tag.as_deref() {
                q.append_pair("tag", t);
            }
            if let Some(a) = opts.is_active {
                q.append_pair("isActive", if a { "true" } else { "false" });
            }
        }

        let (data, meta) = self
            .request::<ApplicationsData, ()>(Method::GET, url, None, None)
            .await?;
        Ok(Page {
            items: data.applications,
            pagination: meta.as_ref().and_then(|m| m.pagination.clone()),
            request_id: meta.and_then(|m| m.request_id),
        })
    }

    /// `GET /v1/applications/{application_id}`
    pub async fn get_application(&self, application_id: &str) -> Result<Application> {
        let url = self.url(&["v1", "applications", application_id])?;
        let (data, _) = self
            .request::<Application, ()>(Method::GET, url, None, None)
            .await?;
        Ok(data)
    }

    // -- Versions -----------------------------------------------------------

    /// `GET /v1/applications/{application_id}/versions`
    pub async fn list_versions(
        &self,
        application_id: &str,
        opts: &ListVersionsOptions,
    ) -> Result<Page<Vec<Version>>> {
        let mut url = self.url(&["v1", "applications", application_id, "versions"])?;
        if let Some(c) = opts.changelog.as_deref() {
            url.query_pairs_mut().append_pair("changelog", c);
        }

        let (data, meta) = self
            .request::<VersionsData, ()>(Method::GET, url, opts.channel, None)
            .await?;
        Ok(Page {
            items: data.versions,
            pagination: meta.as_ref().and_then(|m| m.pagination.clone()),
            request_id: meta.and_then(|m| m.request_id),
        })
    }

    /// `GET /v1/applications/{application_id}/versions/{version}/files`
    pub async fn list_version_files(
        &self,
        application_id: &str,
        version: &str,
    ) -> Result<Vec<VersionFile>> {
        let url = self.url(&["v1", "applications", application_id, "versions", version, "files"])?;
        let (data, _) = self
            .request::<FilesData, ()>(Method::GET, url, None, None)
            .await?;
        Ok(data.files)
    }

    // -- Downloads ----------------------------------------------------------

    /// `GET /v1/downloads/url` — generate a pre-signed download URL.
    pub async fn get_download_info(
        &self,
        application_id: &str,
        version: &str,
        opts: &GetDownloadInfoOptions,
    ) -> Result<DownloadInfo> {
        let mut url = self.url(&["v1", "downloads", "url"])?;
        {
            let mut q = url.query_pairs_mut();
            q.append_pair("applicationId", application_id);
            q.append_pair("version", version);
            if let Some(fid) = opts.file_id.as_deref() {
                q.append_pair("fileId", fid);
            }
        }
        let (data, _) = self
            .request::<DownloadInfo, ()>(Method::GET, url, opts.channel, None)
            .await?;
        Ok(data)
    }

    /// `POST /v1/downloads/share` — mint a public share link.
    pub async fn create_share_link(&self, req: &CreateShareLinkRequest) -> Result<ShareLink> {
        let url = self.url(&["v1", "downloads", "share"])?;
        let (data, _) = self
            .request::<ShareLink, _>(Method::POST, url, None, Some(req))
            .await?;
        Ok(data)
    }

    /// Convenience helper for `GET /v1/downloads/d/{token}`. Returns the
    /// underlying response (which is typically a 302 redirect to a
    /// pre-signed S3 URL). `reqwest` follows redirects by default, so the
    /// body is the downloaded bytes.
    ///
    /// This endpoint requires no authentication.
    pub async fn public_download(&self, token: &str) -> Result<reqwest::Response> {
        let url = self.url(&["v1", "downloads", "d", token])?;
        let resp = self.http.get(url).send().await?;
        if !resp.status().is_success() {
            let status = resp.status();
            let bytes = resp.bytes().await.unwrap_or_default();
            return Err(Error::Api(
                synthesize_error(status, &bytes, None).with_http_status(status.as_u16()),
            ));
        }
        Ok(resp)
    }

    /// Download the bytes for the given application/version. Uses
    /// [`Client::get_download_info`] to obtain the pre-signed URL and
    /// streams it in full.
    pub async fn download(
        &self,
        application_id: &str,
        version: &str,
        opts: &GetDownloadInfoOptions,
    ) -> Result<(DownloadInfo, Bytes)> {
        let info = self
            .get_download_info(application_id, version, opts)
            .await?;
        let resp = self.http.get(&info.url).send().await?;
        let status = resp.status();
        if !status.is_success() {
            let bytes = resp.bytes().await.unwrap_or_default();
            return Err(Error::Api(
                synthesize_error(status, &bytes, None).with_http_status(status.as_u16()),
            ));
        }
        let bytes = resp.bytes().await?;
        Ok((info, bytes))
    }
}

fn synthesize_error(
    status: StatusCode,
    body: &[u8],
    decode_err: Option<serde_json::Error>,
) -> ApiError {
    let mut message = String::new();
    if decode_err.is_none() {
        // Body parsed as JSON but not as our envelope — try a bare
        // {"message": "..."} / {"error": "..."} shape.
        #[derive(serde::Deserialize)]
        struct Bare {
            #[serde(default)]
            message: Option<String>,
            #[serde(default)]
            error: Option<String>,
        }
        if let Ok(bare) = serde_json::from_slice::<Bare>(body) {
            if let Some(m) = bare.message.filter(|s| !s.is_empty()) {
                message = m;
            } else if let Some(m) = bare.error.filter(|s| !s.is_empty()) {
                message = m;
            }
        }
    }
    if message.is_empty() {
        message = status
            .canonical_reason()
            .unwrap_or("http error")
            .to_owned();
    }
    ApiError {
        code: status_code_slug(status).to_owned(),
        message,
        http_status: Some(status.as_u16()),
    }
}

fn status_code_slug(status: StatusCode) -> &'static str {
    match status {
        StatusCode::BAD_REQUEST => "bad_request",
        StatusCode::UNAUTHORIZED => "unauthorized",
        StatusCode::FORBIDDEN => "forbidden",
        StatusCode::NOT_FOUND => "not_found",
        StatusCode::CONFLICT => "conflict",
        StatusCode::TOO_MANY_REQUESTS => "rate_limited",
        s if s.is_server_error() => "server_error",
        s if s.is_client_error() => "http_error",
        _ => "http_error",
    }
}