Skip to main content

voip_ms/
client.rs

1use reqwest::Url;
2use serde::Serialize;
3use serde::de::DeserializeOwned;
4use serde_json::Value;
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 body: Value = response.json().await?;
71        let empty = check_status(&body)?;
72        Ok((body, empty))
73    }
74
75    /// Issue a request for `method` with the given typed parameters and
76    /// return the full JSON response body as a [`serde_json::Value`].
77    ///
78    /// The `status` field is inspected: any value other than `success`
79    /// causes an [`Error::Api`] -- including the empty-collection statuses
80    /// ([`ApiStatus::is_empty`], e.g. `no_sms`). This is the verbatim escape
81    /// hatch: it surfaces exactly what VoIP.ms returned. The typed
82    /// [`Client::call`] instead folds those into an empty response.
83    ///
84    /// This is the low-level raw call used by every generated `*_raw`
85    /// method on [`Client`]. Reach for it directly when VoIP.ms adds a
86    /// method this crate hasn't been regenerated for; otherwise prefer
87    /// the typed [`Client::call`] or one of the per-method wrappers.
88    pub async fn call_raw<P>(&self, method: &str, params: &P) -> Result<Value>
89    where
90        P: Serialize + ?Sized,
91    {
92        let (body, empty) = self.fetch(method, params).await?;
93        if let Some(status) = empty {
94            return Err(Error::Api(status));
95        }
96        Ok(body)
97    }
98
99    /// Issue a request and deserialize the full JSON response body into `T`.
100    ///
101    /// Like [`Client::call_raw`], a non-`success` status is returned as
102    /// [`Error::Api`] -- except an empty-collection status
103    /// ([`ApiStatus::is_empty`]), which deserializes into `T` with its
104    /// collection fields defaulting to `None` rather than erroring.
105    pub async fn call<P, T>(&self, method: &str, params: &P) -> Result<T>
106    where
107        P: Serialize + ?Sized,
108        T: DeserializeOwned,
109    {
110        let (body, _empty) = self.fetch(method, params).await?;
111        serde_json::from_value(body)
112            .map_err(|e| Error::InvalidResponse(format!("failed to deserialize response: {e}")))
113    }
114
115    /// Issue a request and deserialize a JSON subtree selected by JSON pointer.
116    ///
117    /// Use this when the API wraps the interesting data under a known key
118    /// (e.g. `/balance` or `/dids`).
119    ///
120    /// As with [`Client::call`], an empty-collection status
121    /// ([`ApiStatus::is_empty`]) is not an error; it carries no data subtree,
122    /// so the pointer resolves to JSON `null` and `T`'s fields default to
123    /// `None`.
124    pub async fn call_at<P, T>(&self, method: &str, params: &P, pointer: &str) -> Result<T>
125    where
126        P: Serialize + ?Sized,
127        T: DeserializeOwned,
128    {
129        let (body, empty) = self.fetch(method, params).await?;
130        let subtree = match body.pointer(pointer) {
131            Some(v) => v.clone(),
132            None if empty.is_some() => Value::Null,
133            None => {
134                return Err(Error::InvalidResponse(format!(
135                    "response missing JSON pointer `{pointer}` for method `{method}`"
136                )));
137            }
138        };
139
140        serde_json::from_value(subtree).map_err(|e| {
141            Error::InvalidResponse(format!(
142                "failed to deserialize JSON pointer `{pointer}` for method `{method}`: {e}"
143            ))
144        })
145    }
146
147    /// The base URL this client posts to.
148    pub fn base_url(&self) -> &Url {
149        &self.base_url
150    }
151}
152
153/// Builder for [`Client`].
154#[derive(Debug)]
155pub struct ClientBuilder {
156    http: Option<reqwest::Client>,
157    base_url: Option<Url>,
158    api_username: String,
159    api_password: String,
160}
161
162impl ClientBuilder {
163    /// Use a custom [`reqwest::Client`] (e.g. with a proxy, custom timeouts,
164    /// or custom TLS configuration).
165    pub fn http_client(mut self, http: reqwest::Client) -> Self {
166        self.http = Some(http);
167        self
168    }
169
170    /// Override the API base URL. The default is [`DEFAULT_BASE_URL`].
171    pub fn base_url(mut self, url: Url) -> Self {
172        self.base_url = Some(url);
173        self
174    }
175
176    /// Finalize the builder.
177    pub fn build(self) -> Result<Client> {
178        let base_url = match self.base_url {
179            Some(u) => u,
180            None => Url::parse(DEFAULT_BASE_URL).map_err(|e| {
181                Error::InvalidResponse(format!("default base URL failed to parse: {e}"))
182            })?,
183        };
184        let http = self.http.unwrap_or_default();
185        Ok(Client {
186            http,
187            base_url,
188            api_username: self.api_username,
189            api_password: self.api_password,
190        })
191    }
192}
193
194/// Classify a response's `status` field.
195///
196/// Returns `Ok(None)` for `success`, `Err(Error::Api)` for a genuine failure,
197/// and `Ok(Some(status))` for an empty-collection status
198/// ([`ApiStatus::is_empty`], e.g. `no_sms`) -- VoIP.ms's per-method "the list
199/// is empty" code. Whether that case is an error is left to the caller:
200/// [`Client::call_raw`] surfaces it verbatim, while the typed
201/// [`Client::call`] folds it into an empty response.
202fn check_status(body: &Value) -> Result<Option<ApiStatus>> {
203    let status = body
204        .get("status")
205        .and_then(Value::as_str)
206        .ok_or_else(|| Error::InvalidResponse("response missing `status` field".into()))?;
207    if status == "success" {
208        return Ok(None);
209    }
210    let status = ApiStatus::from_wire(status);
211    if status.is_empty() {
212        Ok(Some(status))
213    } else {
214        Err(Error::Api(status))
215    }
216}