Skip to main content

voip_ms/
client.rs

1use reqwest::Url;
2use serde::Serialize;
3use serde::de::DeserializeOwned;
4use serde_json::{Value, json};
5
6use crate::error::{ApiStatus, Error, Result};
7
8/// Default base URL for the VoIP.ms REST API.
9pub const DEFAULT_BASE_URL: &str = "https://voip.ms/api/v1/rest.php";
10
11/// Async client for the VoIP.ms REST API.
12///
13/// Clients are cheap to clone; the underlying [`reqwest::Client`] uses an
14/// internal connection pool that is shared across clones.
15#[derive(Debug, Clone)]
16pub struct Client {
17    http: reqwest::Client,
18    base_url: Url,
19    api_username: String,
20    api_password: String,
21}
22
23impl Client {
24    /// Build a new client with the default base URL and a default
25    /// [`reqwest::Client`]. Use [`Client::builder`] for more control.
26    ///
27    /// # Panics
28    ///
29    /// Panics if the default base URL fails to parse, which would indicate a
30    /// bug in this crate.
31    pub fn new(api_username: impl Into<String>, api_password: impl Into<String>) -> Self {
32        Self::builder(api_username, api_password)
33            .build()
34            .expect("default VoIP.ms base URL must parse")
35    }
36
37    /// Start building a client with custom HTTP client or base URL.
38    pub fn builder(
39        api_username: impl Into<String>,
40        api_password: impl Into<String>,
41    ) -> ClientBuilder {
42        ClientBuilder {
43            http: None,
44            base_url: None,
45            api_username: api_username.into(),
46            api_password: api_password.into(),
47        }
48    }
49
50    /// Issue the GET request for `method` and return the parsed JSON body
51    /// together with its classified status, without rejecting empty-collection
52    /// statuses. The two callers differ only in how they treat that case.
53    async fn fetch<P>(&self, method: &str, params: &P) -> Result<(Value, Option<ApiStatus>)>
54    where
55        P: Serialize + ?Sized,
56    {
57        let response = self
58            .http
59            .get(self.base_url.clone())
60            .query(&[
61                ("api_username", self.api_username.as_str()),
62                ("api_password", self.api_password.as_str()),
63                ("method", method),
64            ])
65            .query(params)
66            .send()
67            .await?
68            .error_for_status()?;
69
70        let text = response.text().await?;
71        // Some methods (e.g. delConference) answer a successful call with an
72        // empty body instead of a `{"status":"success"}` envelope; treat that
73        // as success rather than a JSON parse error.
74        let body: Value = if text.trim().is_empty() {
75            json!({ "status": "success" })
76        } else {
77            serde_json::from_str(&text)
78                .map_err(|e| Error::InvalidResponse(format!("response body is not JSON: {e}")))?
79        };
80        let empty = check_status(&body)?;
81        Ok((body, empty))
82    }
83
84    /// Issue a request for `method` with the given typed parameters and
85    /// return the full JSON response body as a [`serde_json::Value`].
86    ///
87    /// The `status` field is inspected: any value other than `success`
88    /// causes an [`Error::Api`] -- including the empty-collection statuses
89    /// ([`ApiStatus::is_empty`], e.g. `no_sms`). This is the verbatim escape
90    /// hatch: it surfaces exactly what VoIP.ms returned. The typed
91    /// [`Client::call`] instead folds those into an empty response.
92    ///
93    /// This is the low-level raw call used by every generated `*_raw`
94    /// method on [`Client`]. Reach for it directly when VoIP.ms adds a
95    /// method this crate hasn't been regenerated for; otherwise prefer
96    /// the typed [`Client::call`] or one of the per-method wrappers.
97    pub async fn call_raw<P>(&self, method: &str, params: &P) -> Result<Value>
98    where
99        P: Serialize + ?Sized,
100    {
101        let (body, empty) = self.fetch(method, params).await?;
102        if let Some(status) = empty {
103            return Err(Error::Api(status));
104        }
105        Ok(body)
106    }
107
108    /// Issue a request for `method` and return the raw JSON response body
109    /// verbatim, *without* classifying its `status` field -- a non-`success`
110    /// status is returned as-is in the body rather than as an [`Error::Api`].
111    /// Only a transport failure or a missing-JSON body is an `Err`.
112    ///
113    /// Unlike [`Client::call_raw`], this surfaces the whole envelope even for a
114    /// genuine error status, so a caller can inspect exactly what the server
115    /// returned (the status plus any diagnostic fields). Prefer [`Client::call`]
116    /// or [`Client::call_raw`] for normal use; reach for this to diagnose an
117    /// unexpected error status. An empty body reads as `{"status":"success"}`;
118    /// a non-empty body that isn't JSON is an [`Error::InvalidResponse`].
119    ///
120    /// Gated behind the `unchecked-raw` feature.
121    #[cfg(feature = "unchecked-raw")]
122    pub async fn call_raw_unchecked<P>(&self, method: &str, params: &P) -> Result<Value>
123    where
124        P: Serialize + ?Sized,
125    {
126        let response = self
127            .http
128            .get(self.base_url.clone())
129            .query(&[
130                ("api_username", self.api_username.as_str()),
131                ("api_password", self.api_password.as_str()),
132                ("method", method),
133            ])
134            .query(params)
135            .send()
136            .await?
137            .error_for_status()?;
138
139        let text = response.text().await?;
140        if text.trim().is_empty() {
141            return Ok(json!({ "status": "success" }));
142        }
143
144        serde_json::from_str(&text)
145            .map_err(|e| Error::InvalidResponse(format!("response body is not JSON: {e}")))
146    }
147
148    /// Issue a request and deserialize the full JSON response body into `T`.
149    ///
150    /// Like [`Client::call_raw`], a non-`success` status is returned as
151    /// [`Error::Api`] -- except an empty-collection status
152    /// ([`ApiStatus::is_empty`]), which deserializes into `T` with its
153    /// collection fields defaulting to `None` rather than erroring.
154    pub async fn call<P, T>(&self, method: &str, params: &P) -> Result<T>
155    where
156        P: Serialize + ?Sized,
157        T: DeserializeOwned,
158    {
159        let (body, _empty) = self.fetch(method, params).await?;
160        serde_json::from_value(body)
161            .map_err(|e| Error::InvalidResponse(format!("failed to deserialize response: {e}")))
162    }
163
164    /// Issue a request and deserialize a JSON subtree selected by JSON pointer.
165    ///
166    /// Use this when the API wraps the interesting data under a known key
167    /// (e.g. `/balance` or `/dids`).
168    ///
169    /// As with [`Client::call`], an empty-collection status
170    /// ([`ApiStatus::is_empty`]) is not an error; it carries no data subtree,
171    /// so the pointer resolves to JSON `null` and `T`'s fields default to
172    /// `None`.
173    pub async fn call_at<P, T>(&self, method: &str, params: &P, pointer: &str) -> Result<T>
174    where
175        P: Serialize + ?Sized,
176        T: DeserializeOwned,
177    {
178        let (body, empty) = self.fetch(method, params).await?;
179        let subtree = match body.pointer(pointer) {
180            Some(v) => v.clone(),
181            None if empty.is_some() => Value::Null,
182            None => {
183                return Err(Error::InvalidResponse(format!(
184                    "response missing JSON pointer `{pointer}` for method `{method}`"
185                )));
186            }
187        };
188
189        serde_json::from_value(subtree).map_err(|e| {
190            Error::InvalidResponse(format!(
191                "failed to deserialize JSON pointer `{pointer}` for method `{method}`: {e}"
192            ))
193        })
194    }
195
196    /// The base URL this client posts to.
197    pub fn base_url(&self) -> &Url {
198        &self.base_url
199    }
200}
201
202/// Builder for [`Client`].
203#[derive(Debug)]
204pub struct ClientBuilder {
205    http: Option<reqwest::Client>,
206    base_url: Option<Url>,
207    api_username: String,
208    api_password: String,
209}
210
211impl ClientBuilder {
212    /// Use a custom [`reqwest::Client`] (e.g. with a proxy, custom timeouts,
213    /// or custom TLS configuration).
214    pub fn http_client(mut self, http: reqwest::Client) -> Self {
215        self.http = Some(http);
216        self
217    }
218
219    /// Override the API base URL. The default is [`DEFAULT_BASE_URL`].
220    pub fn base_url(mut self, url: Url) -> Self {
221        self.base_url = Some(url);
222        self
223    }
224
225    /// Finalize the builder.
226    pub fn build(self) -> Result<Client> {
227        let base_url = match self.base_url {
228            Some(u) => u,
229            None => Url::parse(DEFAULT_BASE_URL).map_err(|e| {
230                Error::InvalidResponse(format!("default base URL failed to parse: {e}"))
231            })?,
232        };
233        let http = self.http.unwrap_or_default();
234        Ok(Client {
235            http,
236            base_url,
237            api_username: self.api_username,
238            api_password: self.api_password,
239        })
240    }
241}
242
243/// Classify a response's `status` field.
244///
245/// Returns `Ok(None)` for `success`, `Err(Error::Api)` for a genuine failure,
246/// and `Ok(Some(status))` for an empty-collection status
247/// ([`ApiStatus::is_empty`], e.g. `no_sms`) -- VoIP.ms's per-method "the list
248/// is empty" code. Whether that case is an error is left to the caller:
249/// [`Client::call_raw`] surfaces it verbatim, while the typed
250/// [`Client::call`] folds it into an empty response.
251fn check_status(body: &Value) -> Result<Option<ApiStatus>> {
252    let status = body
253        .get("status")
254        .and_then(Value::as_str)
255        .ok_or_else(|| Error::InvalidResponse("response missing `status` field".into()))?;
256    if status == "success" {
257        return Ok(None);
258    }
259    let status = ApiStatus::from_wire(status);
260    if status.is_empty() {
261        Ok(Some(status))
262    } else {
263        Err(Error::Api(status))
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn new_uses_default_base_url() {
273        let c = Client::new("user", "pass");
274        assert_eq!(c.base_url().as_str(), DEFAULT_BASE_URL);
275    }
276
277    #[test]
278    fn builder_overrides_base_url_and_http_client() {
279        let url = Url::parse("https://example.test/api").unwrap();
280        let c = Client::builder("u", "p")
281            .base_url(url.clone())
282            .http_client(reqwest::Client::new())
283            .build()
284            .unwrap();
285        assert_eq!(c.base_url(), &url);
286    }
287
288    #[test]
289    fn clone_shares_configuration() {
290        let c = Client::new("u", "p");
291        let c2 = c.clone();
292        assert_eq!(c.base_url(), c2.base_url());
293    }
294
295    #[test]
296    fn check_status_classifies_each_case() {
297        assert!(matches!(
298            check_status(&serde_json::json!({"status": "success"})),
299            Ok(None)
300        ));
301        assert!(matches!(
302            check_status(&serde_json::json!({"status": "no_sms"})),
303            Ok(Some(_))
304        ));
305        assert!(matches!(
306            check_status(&serde_json::json!({"status": "invalid_credentials"})),
307            Err(Error::Api(_))
308        ));
309        assert!(matches!(
310            check_status(&serde_json::json!({})),
311            Err(Error::InvalidResponse(_))
312        ));
313    }
314}