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}