1use reqwest::Url;
2use serde::Serialize;
3use serde::de::DeserializeOwned;
4use serde_json::{Value, json};
5
6use crate::error::{ApiStatus, Error, Result};
7
8pub const DEFAULT_BASE_URL: &str = "https://voip.ms/api/v1/rest.php";
10
11#[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 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 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 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 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 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 #[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 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 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 pub fn base_url(&self) -> &Url {
198 &self.base_url
199 }
200}
201
202#[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 pub fn http_client(mut self, http: reqwest::Client) -> Self {
215 self.http = Some(http);
216 self
217 }
218
219 pub fn base_url(mut self, url: Url) -> Self {
221 self.base_url = Some(url);
222 self
223 }
224
225 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
243fn 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}