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 a request for `method` with the given typed parameters and
51    /// return the full JSON response body as a [`serde_json::Value`].
52    ///
53    /// The `status` field is inspected: any value other than `success`
54    /// causes an [`Error::Api`].
55    ///
56    /// This is the low-level raw call used by every generated `*_raw`
57    /// method on [`Client`]. Reach for it directly when voip.ms adds a
58    /// method this crate hasn't been regenerated for; otherwise prefer
59    /// the typed [`Client::call`] or one of the per-method wrappers.
60    pub async fn call_raw<P>(&self, method: &str, params: &P) -> Result<Value>
61    where
62        P: Serialize + ?Sized,
63    {
64        let response = self
65            .http
66            .get(self.base_url.clone())
67            .query(&[
68                ("api_username", self.api_username.as_str()),
69                ("api_password", self.api_password.as_str()),
70                ("method", method),
71            ])
72            .query(params)
73            .send()
74            .await?
75            .error_for_status()?;
76
77        let body: Value = response.json().await?;
78        check_status(&body)?;
79        Ok(body)
80    }
81
82    /// Issue a request and deserialize the full JSON response body into `T`.
83    ///
84    /// This applies the same status validation as [`Client::call_raw`]:
85    /// any non-`success` status is returned as [`Error::Api`].
86    pub async fn call<P, T>(&self, method: &str, params: &P) -> Result<T>
87    where
88        P: Serialize + ?Sized,
89        T: DeserializeOwned,
90    {
91        let body = self.call_raw(method, params).await?;
92        serde_json::from_value(body)
93            .map_err(|e| Error::InvalidResponse(format!("failed to deserialize response: {e}")))
94    }
95
96    /// Issue a request and deserialize a JSON subtree selected by JSON pointer.
97    ///
98    /// Use this when the API wraps the interesting data under a known key
99    /// (e.g. `/balance` or `/dids`).
100    pub async fn call_at<P, T>(&self, method: &str, params: &P, pointer: &str) -> Result<T>
101    where
102        P: Serialize + ?Sized,
103        T: DeserializeOwned,
104    {
105        let body = self.call_raw(method, params).await?;
106        let subtree = body.pointer(pointer).cloned().ok_or_else(|| {
107            Error::InvalidResponse(format!(
108                "response missing JSON pointer `{pointer}` for method `{method}`"
109            ))
110        })?;
111
112        serde_json::from_value(subtree).map_err(|e| {
113            Error::InvalidResponse(format!(
114                "failed to deserialize JSON pointer `{pointer}` for method `{method}`: {e}"
115            ))
116        })
117    }
118
119    /// The base URL this client posts to.
120    pub fn base_url(&self) -> &Url {
121        &self.base_url
122    }
123}
124
125/// Builder for [`Client`].
126#[derive(Debug)]
127pub struct ClientBuilder {
128    http: Option<reqwest::Client>,
129    base_url: Option<Url>,
130    api_username: String,
131    api_password: String,
132}
133
134impl ClientBuilder {
135    /// Use a custom [`reqwest::Client`] (e.g. with a proxy, custom timeouts,
136    /// or custom TLS configuration).
137    pub fn http_client(mut self, http: reqwest::Client) -> Self {
138        self.http = Some(http);
139        self
140    }
141
142    /// Override the API base URL. The default is [`DEFAULT_BASE_URL`].
143    pub fn base_url(mut self, url: Url) -> Self {
144        self.base_url = Some(url);
145        self
146    }
147
148    /// Finalize the builder.
149    pub fn build(self) -> Result<Client> {
150        let base_url = match self.base_url {
151            Some(u) => u,
152            None => Url::parse(DEFAULT_BASE_URL).map_err(|e| {
153                Error::InvalidResponse(format!("default base URL failed to parse: {e}"))
154            })?,
155        };
156        let http = self.http.unwrap_or_default();
157        Ok(Client {
158            http,
159            base_url,
160            api_username: self.api_username,
161            api_password: self.api_password,
162        })
163    }
164}
165
166fn check_status(body: &Value) -> Result<()> {
167    let status = body
168        .get("status")
169        .and_then(Value::as_str)
170        .ok_or_else(|| Error::InvalidResponse("response missing `status` field".into()))?;
171    if status == "success" {
172        Ok(())
173    } else {
174        Err(Error::Api(ApiStatus(status.to_owned())))
175    }
176}