bitculator 1.1.0

Official Rust SDK for the Bitculator Data API
Documentation
use std::collections::HashMap;
use std::fmt::Write as _;
use std::sync::Mutex;
use std::thread::sleep;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use reqwest::blocking::Client as ReqwestClient;
use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, USER_AGENT};
use reqwest::Method;
use serde::de::DeserializeOwned;
use serde_json::Value;

use crate::error::{build_api_error, Error};
use crate::params::Params;

/// Quota headers (X-Quota-*) from the most recent response.
#[derive(Debug, Clone, Default)]
pub struct Quota {
    pub limit: Option<i64>,
    pub remaining: Option<i64>,
    pub reset: Option<i64>,
    pub raw: HashMap<String, String>,
}

/// The DRY transport core. Every request in the SDK flows through `send()`:
/// URL and query building, auth headers, JSON (de)serialization, the response
/// envelope, quota capture, retries with backoff, and error mapping.
pub(crate) struct HttpClient {
    api_key: String,
    base_url: String,
    user_agent: String,
    max_retries: u32,
    client: ReqwestClient,
    quota: Mutex<Option<Quota>>,
}

impl HttpClient {
    pub(crate) fn new(
        api_key: String,
        base_url: String,
        user_agent: String,
        max_retries: u32,
        client: ReqwestClient,
    ) -> Self {
        HttpClient {
            api_key,
            base_url,
            user_agent,
            max_retries,
            client,
            quota: Mutex::new(None),
        }
    }

    pub(crate) fn quota(&self) -> Option<Quota> {
        self.quota.lock().unwrap().clone()
    }

    pub(crate) fn get<T: DeserializeOwned>(&self, path: &str, params: &Params) -> Result<T, Error> {
        let value = self.send(Method::GET, path, Some(params), None)?;
        from_value(unwrap(value))
    }

    pub(crate) fn post_value(&self, path: &str, body: Option<Value>) -> Result<Value, Error> {
        let value = self.send(Method::POST, path, None, body)?;
        Ok(unwrap(value))
    }

    pub(crate) fn delete_value(&self, path: &str) -> Result<Value, Error> {
        let value = self.send(Method::DELETE, path, None, None)?;
        Ok(unwrap(value))
    }

    pub(crate) fn send(
        &self,
        method: Method,
        path: &str,
        params: Option<&Params>,
        body: Option<Value>,
    ) -> Result<Value, Error> {
        let url = format!("{}{}", self.base_url, path);
        let auth = format!("Bearer {}", self.api_key);
        // GET and DELETE are idempotent and safe to auto-retry; POST is not — a
        // retried POST could duplicate a server-side side effect the first
        // request already committed (alarm, webhook, vote).
        let is_idempotent = matches!(method, Method::GET | Method::DELETE);
        let mut attempt: u32 = 0;
        loop {
            let mut req = self
                .client
                .request(method.clone(), &url)
                .header(AUTHORIZATION, auth.as_str())
                .header(ACCEPT, "application/json")
                .header(USER_AGENT, self.user_agent.as_str());
            if let Some(p) = params {
                req = req.query(p.pairs());
            }
            if let Some(b) = &body {
                req = req.json(b);
            }

            match req.send() {
                Ok(resp) => {
                    let status = resp.status();
                    *self.quota.lock().unwrap() = Some(parse_quota(resp.headers()));

                    if status.is_success() {
                        let bytes = resp.bytes().map_err(Error::from)?;
                        if bytes.is_empty() {
                            return Ok(Value::Null);
                        }
                        return serde_json::from_slice(&bytes)
                            .map_err(|e| Error::Decode(e.to_string()));
                    }

                    // Cold path only: reading the body consumes the response, so
                    // the headers are cloned first (they feed retry_delay and
                    // build_api_error below).
                    let headers = resp.headers().clone();
                    let bytes = resp.bytes().map_err(Error::from)?;
                    let code = status.as_u16();
                    // 429 is always retryable (the server rejected the request
                    // before processing it); 5xx only for idempotent methods.
                    if (code == 429 || (status.is_server_error() && is_idempotent))
                        && attempt < self.max_retries
                    {
                        sleep(retry_delay(&headers, attempt));
                        attempt += 1;
                        continue;
                    }

                    let value: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
                    return Err(Error::Api(build_api_error(code, &value, &headers)));
                }
                Err(e) => {
                    // Network/timeout/connection errors leave the outcome
                    // unknown — the request may have reached the server. Only
                    // auto-retry idempotent methods; a POST must surface instead.
                    let retryable =
                        is_idempotent && (e.is_timeout() || e.is_connect() || e.is_request());
                    if retryable && attempt < self.max_retries {
                        sleep(backoff(attempt));
                        attempt += 1;
                        continue;
                    }
                    return Err(Error::from(e));
                }
            }
        }
    }
}

// ── helpers (all pure; the single place these concerns are implemented) ──────

/// Percent-encode a dynamic path segment (slug or id) so it is URL-safe.
pub(crate) fn enc(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for b in s.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
                out.push(b as char)
            }
            // write! appends in place (fmt::Write to a String is infallible)
            // instead of allocating a fresh String per escaped byte.
            _ => {
                let _ = write!(out, "%{b:02X}");
            }
        }
    }
    out
}

/// Unwrap the `{ "data": ... }` envelope; pass through bodies with no data key.
fn unwrap(value: Value) -> Value {
    if let Value::Object(mut map) = value {
        if let Some(data) = map.remove("data") {
            return data;
        }
        return Value::Object(map);
    }
    value
}

fn from_value<T: DeserializeOwned>(value: Value) -> Result<T, Error> {
    serde_json::from_value(value).map_err(|e| Error::Decode(e.to_string()))
}

fn parse_quota(headers: &HeaderMap) -> Quota {
    let mut raw = HashMap::new();
    for (name, value) in headers.iter() {
        // HeaderName is guaranteed lowercase, so no case-folding is needed.
        let key = name.as_str();
        if key.starts_with("x-quota-") {
            if let Ok(v) = value.to_str() {
                raw.insert(key.to_string(), v.to_string());
            }
        }
    }
    let num = |name: &str| -> Option<i64> { raw.get(name).and_then(|v| v.parse::<i64>().ok()) };
    Quota {
        limit: num("x-quota-limit"),
        remaining: num("x-quota-remaining"),
        reset: num("x-quota-reset"),
        raw,
    }
}

fn retry_delay(headers: &HeaderMap, attempt: u32) -> Duration {
    let retry_after = headers
        .get("retry-after")
        .and_then(|v| v.to_str().ok())
        .and_then(|s| s.parse::<u64>().ok())
        .filter(|&secs| secs > 0);
    if let Some(secs) = retry_after {
        // Cap at 60s: a misbehaving proxy must not block a synchronous call
        // for an hour.
        return Duration::from_secs(secs.min(60));
    }
    backoff(attempt)
}

/// Exponential backoff with a small time-seeded jitter, capped at 8s.
fn backoff(attempt: u32) -> Duration {
    let ms = (250u64.saturating_mul(1u64 << attempt.min(20))).min(8000);
    let jitter = u64::from(
        SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.subsec_nanos())
            .unwrap_or(0)
            % 100,
    );
    Duration::from_millis(ms + jitter)
}