invoist 1.0.1

Official Rust client for the Invoist API: extract invoices, read a household's bills, verify webhooks.
Documentation
//! The one error type every call returns.

use std::fmt;
use std::time::Duration;

type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;

/// Stable error codes the SDK itself produces. Codes from the API (such as
/// `invalid_api_key` or `insufficient_scope`) arrive as the API sends them.
pub mod codes {
    /// No response arrived: the connection failed or was cut off.
    pub const NETWORK_ERROR: &str = "network_error";
    /// No response arrived within the client's timeout.
    pub const TIMEOUT: &str = "timeout";
    /// The response wasn't what the API sends: an HTML page from a proxy, an
    /// empty body, or JSON that doesn't match the expected shape.
    pub const UNEXPECTED_RESPONSE: &str = "unexpected_response";
    /// [`Client::wait_for_extraction`](crate::Client::wait_for_extraction)
    /// gave up before the extraction finished.
    pub const EXTRACTION_TIMEOUT: &str = "extraction_timeout";
    /// A webhook's signature header is missing, malformed, or doesn't match.
    pub const INVALID_SIGNATURE: &str = "invalid_signature";
    /// A webhook's signature timestamp is outside the tolerance window.
    pub const SIGNATURE_TOO_OLD: &str = "signature_too_old";
    /// A webhook's signature is valid but its body isn't an event.
    pub const INVALID_PAYLOAD: &str = "invalid_payload";
    /// The client was configured wrongly (an empty API key, a bad base URL)
    /// or a method was given an argument it can't send.
    pub const INVALID_ARGUMENT: &str = "invalid_argument";
}

/// Every failure from this crate: an error response from the API, a request
/// that never got one, a webhook that fails verification, or a bad argument.
///
/// Branch on [`code`](Error::code), which is stable, or on the predicates
/// such as [`is_not_found`](Error::is_not_found); never on the message,
/// whose wording can change.
///
/// ```
/// # async fn run(client: invoist::Client) -> Result<(), invoist::Error> {
/// match client.get_document("01a19812-0000-7000-8000-000000000001").await {
///     Ok(detail) => println!("{}", detail.document.original_filename),
///     Err(e) if e.is_not_found() => println!("no such document"),
///     Err(e) => return Err(e),
/// }
/// # Ok(())
/// # }
/// ```
#[derive(thiserror::Error)]
#[error(transparent)]
pub struct Error(Box<Inner>);

#[derive(Debug, thiserror::Error)]
#[error("{message} ({}{})", describe(.status, .code), request_suffix(.request_id))]
struct Inner {
    status: u16,
    code: String,
    message: String,
    request_id: Option<String>,
    body: Option<String>,
    retry_after: Option<Duration>,
    #[source]
    source: Option<BoxError>,
}

fn describe(status: &u16, code: &str) -> String {
    if *status == 0 {
        code.to_string()
    } else {
        format!("status {status}, {code}")
    }
}

fn request_suffix(request_id: &Option<String>) -> String {
    match request_id {
        Some(id) => format!(", request {id}"),
        None => String::new(),
    }
}

impl fmt::Debug for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        fmt::Debug::fmt(&self.0, f)
    }
}

impl Error {
    /// The HTTP status, or `0` when no response arrived (and for errors the
    /// SDK raises itself, such as an extraction that didn't finish in time).
    pub fn status(&self) -> u16 {
        self.0.status
    }

    /// The stable machine-readable code: the API's own (`invalid_api_key`,
    /// `not_found`, …) or one of [`codes`].
    pub fn code(&self) -> &str {
        &self.0.code
    }

    /// Human-readable explanation. Its wording may change: don't parse it.
    pub fn message(&self) -> &str {
        &self.0.message
    }

    /// The response's `X-Request-Id` header. Quote it when asking support.
    pub fn request_id(&self) -> Option<&str> {
        self.0.request_id.as_deref()
    }

    /// The raw response body, when the response had one.
    pub fn body(&self) -> Option<&str> {
        self.0.body.as_deref()
    }

    /// How long the response's `Retry-After` header asked to wait, when it
    /// had one.
    pub fn retry_after(&self) -> Option<Duration> {
        self.0.retry_after
    }

    /// The key is missing, malformed, or revoked (401).
    pub fn is_auth_error(&self) -> bool {
        self.0.status == 401
    }

    /// The key is valid but lacks the scope this call needs, or the route is
    /// for another kind of account (403).
    pub fn is_permission_error(&self) -> bool {
        self.0.status == 403
    }

    /// No such resource (404).
    pub fn is_not_found(&self) -> bool {
        self.0.status == 404
    }

    /// Too many requests (429). [`retry_after`](Error::retry_after) says how
    /// long to wait.
    pub fn is_rate_limited(&self) -> bool {
        self.0.status == 429
    }

    /// No response arrived within the client's timeout.
    pub fn is_timeout(&self) -> bool {
        self.0.code == codes::TIMEOUT
    }

    /// Whether sending the same request again could succeed: no response
    /// arrived, the API was rate limiting (429), or it failed (5xx). A 4xx
    /// other than 429 never is: resending it only burns quota.
    pub fn is_retryable(&self) -> bool {
        self.0.status == 0 || self.0.status == 429 || self.0.status >= 500
    }

    pub(crate) fn new(status: u16, code: &str, message: impl Into<String>) -> Self {
        Error(Box::new(Inner {
            status,
            code: code.to_string(),
            message: message.into(),
            request_id: None,
            body: None,
            retry_after: None,
            source: None,
        }))
    }

    pub(crate) fn invalid_argument(message: impl Into<String>) -> Self {
        Error::new(0, codes::INVALID_ARGUMENT, message)
    }

    pub(crate) fn with_source(mut self, source: impl Into<BoxError>) -> Self {
        self.0.source = Some(source.into());
        self
    }

    /// An error response: reads the API's `{"error":{"code","message"}}`
    /// envelope, and falls back to `unexpected_response` for anything else
    /// (a proxy's HTML page, an empty body).
    pub(crate) fn from_response(
        status: u16,
        request_id: Option<String>,
        retry_after: Option<Duration>,
        body: &[u8],
    ) -> Self {
        let fallback = format!("Request failed with status {status}");
        let envelope = serde_json::from_slice::<serde_json::Value>(body).ok();
        let detail = envelope
            .as_ref()
            .and_then(|v| v.get("error"))
            .and_then(|e| e.as_object());
        let (code, message) = match detail {
            Some(detail) => {
                let code = detail.get("code").and_then(|c| c.as_str());
                let message = detail.get("message").and_then(|m| m.as_str());
                (
                    code.unwrap_or(codes::UNEXPECTED_RESPONSE).to_string(),
                    message.map_or(fallback, str::to_string),
                )
            }
            None => (codes::UNEXPECTED_RESPONSE.to_string(), fallback),
        };
        Error(Box::new(Inner {
            status,
            code,
            message,
            request_id,
            body: (!body.is_empty()).then(|| String::from_utf8_lossy(body).into_owned()),
            retry_after,
            source: None,
        }))
    }

    /// A request that never got a response.
    pub(crate) fn transport(err: reqwest::Error) -> Self {
        let (code, message) = if err.is_timeout() {
            (codes::TIMEOUT, "The request timed out".to_string())
        } else {
            (
                codes::NETWORK_ERROR,
                format!("The request failed before a response arrived: {err}"),
            )
        };
        Error::new(0, code, message).with_source(err)
    }

    /// A success response whose body couldn't be decoded.
    pub(crate) fn decode(
        status: u16,
        request_id: Option<String>,
        body: &[u8],
        err: serde_json::Error,
    ) -> Self {
        let mut e = Error::new(
            status,
            codes::UNEXPECTED_RESPONSE,
            format!("The response could not be read: {err}"),
        )
        .with_source(err);
        e.0.request_id = request_id;
        e.0.body = (!body.is_empty()).then(|| String::from_utf8_lossy(body).into_owned());
        e
    }
}

/// Shorthand for `Result<T, invoist::Error>`.
pub type Result<T, E = Error> = std::result::Result<T, E>;