invoance 0.2.0

Official Rust SDK for the Invoance compliance API
Documentation
//! Low-level HTTP transport built on `reqwest` (async, rustls-tls).

use std::collections::BTreeMap;

use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::{Client, Response};
use serde::de::DeserializeOwned;
use serde_json::Value;

use crate::config::ResolvedConfig;
use crate::error::{error_for_status, Error, RequestContext};
use crate::version::SDK_VERSION;

/// Async HTTP transport shared by every resource handle.
#[derive(Debug)]
pub(crate) struct HttpTransport {
    cfg: ResolvedConfig,
    client: Client,
    base_headers: HeaderMap,
}

/// A query-parameter value. `None` values are skipped when building the query
/// string, matching the reference SDK's behavior.
pub(crate) type QueryParams = Vec<(&'static str, Option<String>)>;

impl HttpTransport {
    pub(crate) fn new(cfg: ResolvedConfig) -> Result<Self, Error> {
        let mut base_headers = HeaderMap::new();
        base_headers.insert(
            reqwest::header::AUTHORIZATION,
            HeaderValue::from_str(&format!("Bearer {}", cfg.api_key))
                .map_err(|e| Error::validation(format!("invalid api key header: {e}")))?,
        );
        base_headers.insert(
            reqwest::header::ACCEPT,
            HeaderValue::from_static("application/json"),
        );
        base_headers.insert(
            reqwest::header::USER_AGENT,
            HeaderValue::from_str(&format!("invoance-rust/{SDK_VERSION}"))
                .map_err(|e| Error::validation(format!("invalid user agent: {e}")))?,
        );
        for (k, v) in &cfg.extra_headers {
            let name = HeaderName::from_bytes(k.as_bytes())
                .map_err(|e| Error::validation(format!("invalid extra header name {k}: {e}")))?;
            let value = HeaderValue::from_str(v).map_err(|e| {
                Error::validation(format!("invalid extra header value for {k}: {e}"))
            })?;
            base_headers.insert(name, value);
        }

        let client = Client::builder()
            .timeout(cfg.timeout)
            .build()
            .map_err(|e| Error::validation(format!("failed to build HTTP client: {e}")))?;

        Ok(Self {
            cfg,
            client,
            base_headers,
        })
    }

    fn build_url(&self, path: &str, params: Option<&QueryParams>) -> String {
        let base = format!("{}/{}{}", self.cfg.base_url, self.cfg.api_version, path);
        let Some(params) = params else { return base };

        let mut pairs: Vec<(String, String)> = Vec::new();
        for (k, v) in params {
            if let Some(v) = v {
                pairs.push(((*k).to_string(), v.clone()));
            }
        }
        if pairs.is_empty() {
            return base;
        }
        let qs = serde_urlencoded_lite(&pairs);
        format!("{base}?{qs}")
    }

    // ── Public verbs ──────────────────────────────────────────

    pub(crate) async fn get<T: DeserializeOwned>(
        &self,
        path: &str,
        params: Option<QueryParams>,
    ) -> Result<T, Error> {
        let url = self.build_url(path, params.as_ref());
        let ctx = RequestContext {
            method: "GET".into(),
            path: path.into(),
        };
        let resp = self
            .send(
                self.client.get(&url).headers(self.base_headers.clone()),
                &ctx,
            )
            .await?;
        self.handle_json(resp, ctx).await
    }

    pub(crate) async fn get_raw(&self, path: &str) -> Result<Value, Error> {
        self.get::<Value>(path, None).await
    }

    pub(crate) async fn post<T: DeserializeOwned>(
        &self,
        path: &str,
        body: Option<&Value>,
        idempotency_key: Option<&str>,
    ) -> Result<T, Error> {
        let url = self.build_url(path, None);
        let ctx = RequestContext {
            method: "POST".into(),
            path: path.into(),
        };
        let mut headers = self.base_headers.clone();
        headers.insert(
            reqwest::header::CONTENT_TYPE,
            HeaderValue::from_static("application/json"),
        );
        if let Some(idem) = idempotency_key.or(self.cfg.idempotency_key.as_deref()) {
            headers.insert(
                HeaderName::from_static("idempotency-key"),
                HeaderValue::from_str(idem)
                    .map_err(|e| Error::validation(format!("invalid idempotency key: {e}")))?,
            );
        }
        let mut req = self.client.post(&url).headers(headers);
        if let Some(b) = body {
            req = req.json(b);
        }
        let resp = self.send(req, &ctx).await?;
        self.handle_json(resp, ctx).await
    }

    pub(crate) async fn put<T: DeserializeOwned>(
        &self,
        path: &str,
        body: Option<&Value>,
    ) -> Result<T, Error> {
        let url = self.build_url(path, None);
        let ctx = RequestContext {
            method: "PUT".into(),
            path: path.into(),
        };
        let mut headers = self.base_headers.clone();
        headers.insert(
            reqwest::header::CONTENT_TYPE,
            HeaderValue::from_static("application/json"),
        );
        let mut req = self.client.put(&url).headers(headers);
        if let Some(b) = body {
            req = req.json(b);
        }
        let resp = self.send(req, &ctx).await?;
        self.handle_json(resp, ctx).await
    }

    pub(crate) async fn patch<T: DeserializeOwned>(
        &self,
        path: &str,
        body: Option<&Value>,
    ) -> Result<T, Error> {
        let url = self.build_url(path, None);
        let ctx = RequestContext {
            method: "PATCH".into(),
            path: path.into(),
        };
        let mut headers = self.base_headers.clone();
        headers.insert(
            reqwest::header::CONTENT_TYPE,
            HeaderValue::from_static("application/json"),
        );
        let mut req = self.client.patch(&url).headers(headers);
        if let Some(b) = body {
            req = req.json(b);
        }
        let resp = self.send(req, &ctx).await?;
        self.handle_json(resp, ctx).await
    }

    pub(crate) async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
        let url = self.build_url(path, None);
        let ctx = RequestContext {
            method: "DELETE".into(),
            path: path.into(),
        };
        let resp = self
            .send(
                self.client.delete(&url).headers(self.base_headers.clone()),
                &ctx,
            )
            .await?;
        self.handle_json(resp, ctx).await
    }

    /// GET that returns raw bytes; sets `Accept: application/octet-stream` and
    /// drops `Content-Type`.
    pub(crate) async fn get_bytes(&self, path: &str) -> Result<Vec<u8>, Error> {
        let url = self.build_url(path, None);
        let ctx = RequestContext {
            method: "GET".into(),
            path: path.into(),
        };
        let mut headers = self.base_headers.clone();
        headers.insert(
            reqwest::header::ACCEPT,
            HeaderValue::from_static("application/octet-stream"),
        );
        headers.remove(reqwest::header::CONTENT_TYPE);

        let resp = self
            .send(self.client.get(&url).headers(headers), &ctx)
            .await?;

        let status = resp.status().as_u16();
        let retry_after = parse_retry_after(header_str(resp.headers(), "retry-after"));

        let bytes = resp.bytes().await.map_err(|e| map_reqwest_error(e, &ctx))?;

        if !(200..300).contains(&status) {
            let body = serde_json::from_slice::<BTreeMap<String, Value>>(&bytes).ok();
            error_for_status(status, body, Some(ctx), retry_after)?;
        }
        Ok(bytes.to_vec())
    }

    // ── Internals ─────────────────────────────────────────────

    async fn send(
        &self,
        req: reqwest::RequestBuilder,
        ctx: &RequestContext,
    ) -> Result<Response, Error> {
        req.send().await.map_err(|e| map_reqwest_error(e, ctx))
    }

    async fn handle_json<T: DeserializeOwned>(
        &self,
        resp: Response,
        ctx: RequestContext,
    ) -> Result<T, Error> {
        let status = resp.status().as_u16();
        let retry_after = parse_retry_after(header_str(resp.headers(), "retry-after"));

        let text = resp.text().await.map_err(|e| map_reqwest_error(e, &ctx))?;

        // Parse the body as a JSON object map for error extraction (empty or
        // non-JSON bodies leave `body` as None, matching the reference SDK).
        let body_map: Option<BTreeMap<String, Value>> = if text.trim().is_empty() {
            None
        } else {
            serde_json::from_str::<BTreeMap<String, Value>>(&text).ok()
        };

        error_for_status(status, body_map, Some(ctx.clone()), retry_after)?;

        // Success: deserialize into the requested type.
        if text.trim().is_empty() {
            // Attempt to deserialize `null` for unit-like responses.
            serde_json::from_str::<T>("null").map_err(|e| {
                Error::validation(format!(
                    "empty response body could not be deserialized on {} {}: {e}",
                    ctx.method, ctx.path
                ))
            })
        } else {
            serde_json::from_str::<T>(&text).map_err(|e| {
                Error::validation(format!(
                    "failed to deserialize response on {} {}: {e}",
                    ctx.method, ctx.path
                ))
            })
        }
    }
}

fn map_reqwest_error(err: reqwest::Error, ctx: &RequestContext) -> Error {
    if err.is_timeout() {
        Error::timeout(
            format!("Request timed out on {} {}: {err}", ctx.method, ctx.path),
            Some(ctx.clone()),
            Some(Box::new(err)),
        )
    } else {
        Error::network(
            format!("Network failure on {} {}: {err}", ctx.method, ctx.path),
            Some(ctx.clone()),
            Some(Box::new(err)),
        )
    }
}

fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
    headers.get(name).and_then(|v| v.to_str().ok())
}

/// Parse a `Retry-After` header: numeric seconds, or an HTTP-date converted to
/// a delta from now (floored at 0).
fn parse_retry_after(value: Option<&str>) -> Option<f64> {
    let value = value?;
    if let Ok(secs) = value.trim().parse::<f64>() {
        if secs.is_finite() && secs >= 0.0 {
            return Some(secs);
        }
    }
    // HTTP-date form (RFC 7231 IMF-fixdate) → delta seconds.
    if let Some(ts) = parse_http_date_epoch(value) {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs_f64())
            .unwrap_or(0.0);
        return Some((ts - now).max(0.0));
    }
    None
}

/// Minimal IMF-fixdate parser: `Wed, 21 Oct 2015 07:28:00 GMT` → unix seconds.
fn parse_http_date_epoch(s: &str) -> Option<f64> {
    let s = s.trim();
    // Split off the weekday prefix "Xxx, ".
    let rest = s.split_once(", ").map(|(_, r)| r).unwrap_or(s);
    let parts: Vec<&str> = rest.split_whitespace().collect();
    if parts.len() < 5 {
        return None;
    }
    let day: i64 = parts[0].parse().ok()?;
    let month = match parts[1] {
        "Jan" => 1,
        "Feb" => 2,
        "Mar" => 3,
        "Apr" => 4,
        "May" => 5,
        "Jun" => 6,
        "Jul" => 7,
        "Aug" => 8,
        "Sep" => 9,
        "Oct" => 10,
        "Nov" => 11,
        "Dec" => 12,
        _ => return None,
    };
    let year: i64 = parts[2].parse().ok()?;
    let time: Vec<&str> = parts[3].split(':').collect();
    if time.len() != 3 {
        return None;
    }
    let hh: i64 = time[0].parse().ok()?;
    let mm: i64 = time[1].parse().ok()?;
    let ss: i64 = time[2].parse().ok()?;
    Some(days_from_civil(year, month, day) as f64 * 86400.0 + (hh * 3600 + mm * 60 + ss) as f64)
}

/// Days since the Unix epoch for a civil (proleptic Gregorian) date.
/// Howard Hinnant's algorithm.
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
    let y = if m <= 2 { y - 1 } else { y };
    let era = if y >= 0 { y } else { y - 399 } / 400;
    let yoe = y - era * 400;
    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    era * 146097 + doe - 719468
}

/// Tiny URL-encoder for query strings (avoids pulling in a crate for a handful
/// of key/value pairs). Encodes per `application/x-www-form-urlencoded`.
fn serde_urlencoded_lite(pairs: &[(String, String)]) -> String {
    pairs
        .iter()
        .map(|(k, v)| format!("{}={}", encode_component(k), encode_component(v)))
        .collect::<Vec<_>>()
        .join("&")
}

fn encode_component(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for &b in s.as_bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(b as char)
            }
            b' ' => out.push_str("%20"),
            _ => out.push_str(&format!("%{b:02X}")),
        }
    }
    out
}