Skip to main content

bizowie_api/
lib.rs

1//! Async Rust client for [Bizowie's](https://bizowie.com) ERP API.
2//!
3//! Port of the Perl [`WWW::Bizowie::API`](https://github.com/bizowie/WWW-Bizowie-API)
4//! module. Supports both the v1 and v2 API endpoints.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use bizowie_api::BizowieAPI;
10//! use serde_json::json;
11//!
12//! # async fn run() -> Result<(), bizowie_api::Error> {
13//! let bz = BizowieAPI::new(
14//!     "02cc7058-cd22-4c8e-ad7c-a8f3f2a64bd0",
15//!     "58c57abc-1e16-3571-bb35-73876bcef746",
16//!     "mysite.bizowie.com",
17//! )
18//! .v2(true);
19//!
20//! let res = bz
21//!     .call("databases/add_note/3/10/123", Some(&json!({ "comment": "hi from Rust" })))
22//!     .await?;
23//!
24//! if res.success == 1 {
25//!     println!("ok: {}", res.data);
26//! } else {
27//!     eprintln!("failed: {}", res.data);
28//! }
29//! # Ok(())
30//! # }
31//! ```
32
33use serde_json::{json, Value};
34use std::fmt;
35
36const USER_AGENT_STR: &str = "Bizowie::API";
37
38/// Errors returned by [`BizowieAPI::call`].
39#[derive(Debug)]
40pub enum Error {
41    /// A network-level failure (DNS, connection refused, TLS error, etc.).
42    Http(reqwest::Error),
43    /// The `method` argument to [`BizowieAPI::call`] was empty.
44    MissingMethod,
45}
46
47impl fmt::Display for Error {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Error::Http(e) => write!(f, "HTTP error: {}", e),
51            Error::MissingMethod => {
52                write!(f, "[Bizowie::API] fatal error: no method given")
53            }
54        }
55    }
56}
57
58impl std::error::Error for Error {
59    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
60        match self {
61            Error::Http(e) => Some(e),
62            Error::MissingMethod => None,
63        }
64    }
65}
66
67impl From<reqwest::Error> for Error {
68    fn from(e: reqwest::Error) -> Self {
69        Error::Http(e)
70    }
71}
72
73/// Response returned by [`BizowieAPI::call`].
74///
75/// `success` is lifted from the response body; everything else stays on
76/// `data`. If the body could not be parsed as JSON, `data` is
77/// `{ "unprocessed": 1 }` and `success` is `0`.
78#[derive(Debug, Clone)]
79pub struct BizowieAPIResponse {
80    /// Decoded JSON response body (with the top-level `success` field removed).
81    pub data: Value,
82    /// `1` on success, `0` otherwise.
83    pub success: i64,
84}
85
86/// Async client for the Bizowie ERP API.
87///
88/// Use [`BizowieAPI::new`] to construct, then chain optional builder methods
89/// like [`v2`](Self::v2), [`api_version`](Self::api_version), and
90/// [`debug`](Self::debug).
91pub struct BizowieAPI {
92    api_key: String,
93    secret_key: String,
94    site: String,
95    v2: bool,
96    api_version: Option<String>,
97    debug: bool,
98    client: reqwest::Client,
99}
100
101impl BizowieAPI {
102    /// Create a new client.
103    ///
104    /// # Panics
105    ///
106    /// Panics if the underlying HTTP client cannot be built (e.g., the TLS
107    /// backend fails to initialize). This should not happen in practice.
108    pub fn new(
109        api_key: impl Into<String>,
110        secret_key: impl Into<String>,
111        site: impl Into<String>,
112    ) -> Self {
113        Self {
114            api_key: api_key.into(),
115            secret_key: secret_key.into(),
116            site: site.into(),
117            v2: false,
118            api_version: None,
119            debug: false,
120            client: reqwest::Client::builder()
121                .user_agent(USER_AGENT_STR)
122                .build()
123                .expect("failed to build reqwest client"),
124        }
125    }
126
127    /// Route calls through the v2 endpoint (`/bz/apiv2/call/`). Recommended
128    /// for new integrations.
129    pub fn v2(mut self, v2: bool) -> Self {
130        self.v2 = v2;
131        self
132    }
133
134    /// API version sent with each v2 request. Defaults to `"1.00"` when
135    /// unset.
136    pub fn api_version(mut self, version: impl Into<String>) -> Self {
137        self.api_version = Some(version.into());
138        self
139    }
140
141    /// When `true`, log the raw HTTP body to stderr if the response can't
142    /// be parsed as JSON.
143    pub fn debug(mut self, debug: bool) -> Self {
144        self.debug = debug;
145        self
146    }
147
148    /// Make an API call. Dispatches to the v1 or v2 endpoint based on
149    /// [`v2`](Self::v2).
150    ///
151    /// Does **not** return an error for HTTP-level failures (4xx/5xx) —
152    /// those are surfaced via `success: 0` on the returned response.
153    /// Network-level failures (DNS, connection refused, TLS) are returned
154    /// as [`Error::Http`].
155    ///
156    /// `method` is everything after `/bz/api/` (v1) or `/bz/apiv2/call/`
157    /// (v2). In v2 mode, `api_key`/`secret_key`/`api_version` are injected
158    /// automatically — don't include them in `params`.
159    pub async fn call(
160        &self,
161        method: &str,
162        params: Option<&Value>,
163    ) -> Result<BizowieAPIResponse, Error> {
164        if self.v2 {
165            self.call_v2(method, params).await
166        } else {
167            self.call_v1(method, params).await
168        }
169    }
170
171    async fn call_v1(
172        &self,
173        method: &str,
174        params: Option<&Value>,
175    ) -> Result<BizowieAPIResponse, Error> {
176        if method.is_empty() {
177            return Err(Error::MissingMethod);
178        }
179
180        let empty = json!({});
181        let request_body = serde_json::to_string(params.unwrap_or(&empty))
182            .expect("serializing serde_json::Value to String cannot fail");
183
184        let form = reqwest::multipart::Form::new()
185            .text("api_key", self.api_key.clone())
186            .text("secret_key", self.secret_key.clone())
187            .text("site", self.site.clone())
188            .text("request", request_body);
189
190        let res = self
191            .client
192            .post(format!("https://{}/bz/api/{}", self.site, method))
193            .multipart(form)
194            .send()
195            .await?;
196
197        self.parse_response(res).await
198    }
199
200    async fn call_v2(
201        &self,
202        method: &str,
203        params: Option<&Value>,
204    ) -> Result<BizowieAPIResponse, Error> {
205        if method.is_empty() {
206            return Err(Error::MissingMethod);
207        }
208
209        let mut payload = match params {
210            Some(Value::Object(map)) => map.clone(),
211            Some(other) => {
212                let mut m = serde_json::Map::new();
213                m.insert("_params".into(), other.clone());
214                m
215            }
216            None => serde_json::Map::new(),
217        };
218        payload.insert("api_key".into(), Value::String(self.api_key.clone()));
219        payload.insert("secret_key".into(), Value::String(self.secret_key.clone()));
220        payload
221            .entry("api_version".to_string())
222            .or_insert_with(|| {
223                Value::String(self.api_version.clone().unwrap_or_else(|| "1.00".to_string()))
224            });
225
226        let body = serde_json::to_string(&Value::Object(payload))
227            .expect("serializing serde_json::Value to String cannot fail");
228
229        let res = self
230            .client
231            .post(format!("https://{}/bz/apiv2/call/{}", self.site, method))
232            .header(reqwest::header::CONTENT_TYPE, "form-data")
233            .body(body)
234            .send()
235            .await?;
236
237        self.parse_response(res).await
238    }
239
240    async fn parse_response(
241        &self,
242        res: reqwest::Response,
243    ) -> Result<BizowieAPIResponse, Error> {
244        let status = res.status();
245        let text = res.text().await?;
246
247        let mut data: Value = match serde_json::from_str(&text) {
248            Ok(v) => v,
249            Err(_) => {
250                if self.debug {
251                    eprintln!("[Bizowie::API] {}\n{}", status, text);
252                }
253                json!({ "unprocessed": 1 })
254            }
255        };
256
257        let success = match data.as_object_mut().and_then(|m| m.remove("success")) {
258            Some(Value::Number(n)) => n.as_i64().unwrap_or(0),
259            Some(Value::Bool(b)) => {
260                if b {
261                    1
262                } else {
263                    0
264                }
265            }
266            _ => 0,
267        };
268
269        Ok(BizowieAPIResponse { data, success })
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn builder_chain() {
279        let bz = BizowieAPI::new("a", "b", "c")
280            .v2(true)
281            .api_version("1.00")
282            .debug(true);
283        assert!(bz.v2);
284        assert_eq!(bz.api_version.as_deref(), Some("1.00"));
285        assert!(bz.debug);
286        assert_eq!(bz.api_key, "a");
287        assert_eq!(bz.secret_key, "b");
288        assert_eq!(bz.site, "c");
289    }
290}