bitculator 1.1.0

Official Rust SDK for the Bitculator Data API
Documentation
use std::fmt;

use reqwest::header::HeaderMap;
use serde_json::Value;

/// A non-2xx response, carrying the API's `{ error: { code, message, details } }`
/// envelope.
#[derive(Debug, Clone)]
pub struct ApiError {
    pub status: u16,
    pub code: Option<String>,
    pub message: String,
    pub details: Option<Value>,
    pub request_id: Option<String>,
    /// Seconds to wait before retrying (429 only).
    pub retry_after: Option<u64>,
}

impl ApiError {
    pub fn is_auth(&self) -> bool {
        self.status == 401
    }
    pub fn is_permission(&self) -> bool {
        self.status == 403
    }
    pub fn is_not_found(&self) -> bool {
        self.status == 404
    }
    pub fn is_validation(&self) -> bool {
        self.status == 422
    }
    pub fn is_rate_limited(&self) -> bool {
        self.status == 429
    }
    pub fn is_server(&self) -> bool {
        self.status >= 500
    }
}

/// Any error the SDK can return.
#[derive(Debug)]
pub enum Error {
    /// A non-2xx API response.
    Api(ApiError),
    /// The request timed out (after retries).
    Timeout,
    /// A network-level failure.
    Connection(String),
    /// Failed to decode a response body.
    Decode(String),
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Error::Api(e) => write!(f, "bitculator: HTTP {}: {}", e.status, e.message),
            Error::Timeout => write!(f, "bitculator: request timed out"),
            Error::Connection(m) => write!(f, "bitculator: network error: {m}"),
            Error::Decode(m) => write!(f, "bitculator: failed to decode response: {m}"),
        }
    }
}

impl std::error::Error for Error {}

impl From<reqwest::Error> for Error {
    fn from(e: reqwest::Error) -> Self {
        if e.is_timeout() {
            Error::Timeout
        } else if e.is_decode() {
            Error::Decode(e.to_string())
        } else {
            Error::Connection(e.to_string())
        }
    }
}

pub(crate) fn build_api_error(status: u16, body: &Value, headers: &HeaderMap) -> ApiError {
    let mut code = None;
    let mut message = format!("HTTP {status}");
    let mut details = None;

    if let Some(err) = body.get("error") {
        if let Some(c) = err.get("code").and_then(Value::as_str) {
            code = Some(c.to_string());
        }
        if let Some(m) = err.get("message").and_then(Value::as_str) {
            message = m.to_string();
        }
        details = err.get("details").cloned();
    } else if let Some(m) = body.get("message").and_then(Value::as_str) {
        message = m.to_string();
    }

    let request_id = headers
        .get("x-request-id")
        .and_then(|v| v.to_str().ok())
        .map(String::from);

    let retry_after = if status == 429 {
        headers
            .get("retry-after")
            .and_then(|v| v.to_str().ok())
            .and_then(|s| s.parse::<u64>().ok())
    } else {
        None
    };

    ApiError { status, code, message, details, request_id, retry_after }
}