bizowie-api 0.5.0

Async Rust client for Bizowie's ERP API.
Documentation
//! Async Rust client for [Bizowie's](https://bizowie.com) ERP API.
//!
//! Port of the Perl [`WWW::Bizowie::API`](https://github.com/bizowie/WWW-Bizowie-API)
//! module. Supports both the v1 and v2 API endpoints.
//!
//! # Example
//!
//! ```no_run
//! use bizowie_api::BizowieAPI;
//! use serde_json::json;
//!
//! # async fn run() -> Result<(), bizowie_api::Error> {
//! let bz = BizowieAPI::new(
//!     "02cc7058-cd22-4c8e-ad7c-a8f3f2a64bd0",
//!     "58c57abc-1e16-3571-bb35-73876bcef746",
//!     "mysite.bizowie.com",
//! )
//! .v2(true);
//!
//! let res = bz
//!     .call("databases/add_note/3/10/123", Some(&json!({ "comment": "hi from Rust" })))
//!     .await?;
//!
//! if res.success == 1 {
//!     println!("ok: {}", res.data);
//! } else {
//!     eprintln!("failed: {}", res.data);
//! }
//! # Ok(())
//! # }
//! ```

use serde_json::{json, Value};
use std::fmt;

const USER_AGENT_STR: &str = "Bizowie::API";

/// Errors returned by [`BizowieAPI::call`].
#[derive(Debug)]
pub enum Error {
    /// A network-level failure (DNS, connection refused, TLS error, etc.).
    Http(reqwest::Error),
    /// The `method` argument to [`BizowieAPI::call`] was empty.
    MissingMethod,
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Error::Http(e) => write!(f, "HTTP error: {}", e),
            Error::MissingMethod => {
                write!(f, "[Bizowie::API] fatal error: no method given")
            }
        }
    }
}

impl std::error::Error for Error {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Error::Http(e) => Some(e),
            Error::MissingMethod => None,
        }
    }
}

impl From<reqwest::Error> for Error {
    fn from(e: reqwest::Error) -> Self {
        Error::Http(e)
    }
}

/// Response returned by [`BizowieAPI::call`].
///
/// `success` is lifted from the response body; everything else stays on
/// `data`. If the body could not be parsed as JSON, `data` is
/// `{ "unprocessed": 1 }` and `success` is `0`.
#[derive(Debug, Clone)]
pub struct BizowieAPIResponse {
    /// Decoded JSON response body (with the top-level `success` field removed).
    pub data: Value,
    /// `1` on success, `0` otherwise.
    pub success: i64,
}

/// Async client for the Bizowie ERP API.
///
/// Use [`BizowieAPI::new`] to construct, then chain optional builder methods
/// like [`v2`](Self::v2), [`api_version`](Self::api_version), and
/// [`debug`](Self::debug).
pub struct BizowieAPI {
    api_key: String,
    secret_key: String,
    site: String,
    v2: bool,
    api_version: Option<String>,
    debug: bool,
    client: reqwest::Client,
}

impl BizowieAPI {
    /// Create a new client.
    ///
    /// # Panics
    ///
    /// Panics if the underlying HTTP client cannot be built (e.g., the TLS
    /// backend fails to initialize). This should not happen in practice.
    pub fn new(
        api_key: impl Into<String>,
        secret_key: impl Into<String>,
        site: impl Into<String>,
    ) -> Self {
        Self {
            api_key: api_key.into(),
            secret_key: secret_key.into(),
            site: site.into(),
            v2: false,
            api_version: None,
            debug: false,
            client: reqwest::Client::builder()
                .user_agent(USER_AGENT_STR)
                .build()
                .expect("failed to build reqwest client"),
        }
    }

    /// Route calls through the v2 endpoint (`/bz/apiv2/call/`). Recommended
    /// for new integrations.
    pub fn v2(mut self, v2: bool) -> Self {
        self.v2 = v2;
        self
    }

    /// API version sent with each v2 request. Defaults to `"1.00"` when
    /// unset.
    pub fn api_version(mut self, version: impl Into<String>) -> Self {
        self.api_version = Some(version.into());
        self
    }

    /// When `true`, log the raw HTTP body to stderr if the response can't
    /// be parsed as JSON.
    pub fn debug(mut self, debug: bool) -> Self {
        self.debug = debug;
        self
    }

    /// Make an API call. Dispatches to the v1 or v2 endpoint based on
    /// [`v2`](Self::v2).
    ///
    /// Does **not** return an error for HTTP-level failures (4xx/5xx) —
    /// those are surfaced via `success: 0` on the returned response.
    /// Network-level failures (DNS, connection refused, TLS) are returned
    /// as [`Error::Http`].
    ///
    /// `method` is everything after `/bz/api/` (v1) or `/bz/apiv2/call/`
    /// (v2). In v2 mode, `api_key`/`secret_key`/`api_version` are injected
    /// automatically — don't include them in `params`.
    pub async fn call(
        &self,
        method: &str,
        params: Option<&Value>,
    ) -> Result<BizowieAPIResponse, Error> {
        if self.v2 {
            self.call_v2(method, params).await
        } else {
            self.call_v1(method, params).await
        }
    }

    async fn call_v1(
        &self,
        method: &str,
        params: Option<&Value>,
    ) -> Result<BizowieAPIResponse, Error> {
        if method.is_empty() {
            return Err(Error::MissingMethod);
        }

        let empty = json!({});
        let request_body = serde_json::to_string(params.unwrap_or(&empty))
            .expect("serializing serde_json::Value to String cannot fail");

        let form = reqwest::multipart::Form::new()
            .text("api_key", self.api_key.clone())
            .text("secret_key", self.secret_key.clone())
            .text("site", self.site.clone())
            .text("request", request_body);

        let res = self
            .client
            .post(format!("https://{}/bz/api/{}", self.site, method))
            .multipart(form)
            .send()
            .await?;

        self.parse_response(res).await
    }

    async fn call_v2(
        &self,
        method: &str,
        params: Option<&Value>,
    ) -> Result<BizowieAPIResponse, Error> {
        if method.is_empty() {
            return Err(Error::MissingMethod);
        }

        let mut payload = match params {
            Some(Value::Object(map)) => map.clone(),
            Some(other) => {
                let mut m = serde_json::Map::new();
                m.insert("_params".into(), other.clone());
                m
            }
            None => serde_json::Map::new(),
        };
        payload.insert("api_key".into(), Value::String(self.api_key.clone()));
        payload.insert("secret_key".into(), Value::String(self.secret_key.clone()));
        payload
            .entry("api_version".to_string())
            .or_insert_with(|| {
                Value::String(self.api_version.clone().unwrap_or_else(|| "1.00".to_string()))
            });

        let body = serde_json::to_string(&Value::Object(payload))
            .expect("serializing serde_json::Value to String cannot fail");

        let res = self
            .client
            .post(format!("https://{}/bz/apiv2/call/{}", self.site, method))
            .header(reqwest::header::CONTENT_TYPE, "form-data")
            .body(body)
            .send()
            .await?;

        self.parse_response(res).await
    }

    async fn parse_response(
        &self,
        res: reqwest::Response,
    ) -> Result<BizowieAPIResponse, Error> {
        let status = res.status();
        let text = res.text().await?;

        let mut data: Value = match serde_json::from_str(&text) {
            Ok(v) => v,
            Err(_) => {
                if self.debug {
                    eprintln!("[Bizowie::API] {}\n{}", status, text);
                }
                json!({ "unprocessed": 1 })
            }
        };

        let success = match data.as_object_mut().and_then(|m| m.remove("success")) {
            Some(Value::Number(n)) => n.as_i64().unwrap_or(0),
            Some(Value::Bool(b)) => {
                if b {
                    1
                } else {
                    0
                }
            }
            _ => 0,
        };

        Ok(BizowieAPIResponse { data, success })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn builder_chain() {
        let bz = BizowieAPI::new("a", "b", "c")
            .v2(true)
            .api_version("1.00")
            .debug(true);
        assert!(bz.v2);
        assert_eq!(bz.api_version.as_deref(), Some("1.00"));
        assert!(bz.debug);
        assert_eq!(bz.api_key, "a");
        assert_eq!(bz.secret_key, "b");
        assert_eq!(bz.site, "c");
    }
}