sms_client/http/
mod.rs

1//! SMS-API HTTP client.
2//! This can be used to interface with the HTTP API standalone if required.
3
4use crate::http::error::{HttpError, HttpResult};
5use crate::http::types::{
6    HttpModemBatteryLevelResponse, HttpModemNetworkOperatorResponse,
7    HttpModemNetworkStatusResponse, HttpModemSignalStrengthResponse, HttpOutgoingSmsMessage,
8    HttpPaginationOptions, HttpSmsDeliveryReport, HttpSmsDeviceInfoData, HttpSmsDeviceInfoResponse,
9    HttpSmsSendResponse, LatestNumberFriendlyNamePair,
10};
11
12pub mod error;
13pub mod paginator;
14pub mod types;
15
16/// Take a response from the client, verify that the status code is 200,
17/// then read JSON body and ensure success is true and finally return response value.
18async fn read_http_response<T>(response: reqwest::Response) -> HttpResult<T>
19where
20    T: serde::de::DeserializeOwned,
21{
22    let is_json = response
23        .headers()
24        .get(reqwest::header::CONTENT_TYPE)
25        .and_then(|ct| ct.to_str().ok())
26        .map(|ct| ct.contains("application/json"))
27        .unwrap_or(false);
28
29    if is_json {
30        // Verify JSON success status.
31        let json: serde_json::Value = response.json().await?;
32        let success = json
33            .get("success")
34            .and_then(serde_json::Value::as_bool)
35            .unwrap_or(false);
36
37        if !success {
38            let message = json
39                .get("error")
40                .and_then(|v| v.as_str())
41                .unwrap_or("Unknown API error!")
42                .to_string();
43
44            return Err(HttpError::ApiError(message));
45        }
46
47        // Read response field and make into expected value.
48        let response_value = json
49            .get("response")
50            .ok_or(HttpError::MissingResponseField)?;
51
52        return serde_json::from_value(response_value.clone()).map_err(HttpError::JsonError);
53    }
54
55    // Return a status error if there isn't any JSON error to use.
56    let status = response.status();
57    if !status.is_success() {
58        let error_text = response
59            .text()
60            .await
61            .unwrap_or_else(|_| "Unknown error!".to_string());
62
63        return Err(HttpError::HttpStatus {
64            status: status.as_u16(),
65            message: error_text,
66        });
67    }
68
69    Err(HttpError::MissingResponseField)
70}
71
72/// Read a modem-specific response that contains a "type" field and "data" field.
73/// Verifies the type matches the expected type before returning the data.
74async fn read_modem_response<T>(expected: &str, response: reqwest::Response) -> HttpResult<T>
75where
76    T: serde::de::DeserializeOwned,
77{
78    // Verify expected response type.
79    let json_response: serde_json::Value = read_http_response(response).await?;
80    let actual = json_response
81        .get("type")
82        .and_then(|v| v.as_str())
83        .ok_or(HttpError::MissingTypeField)?;
84
85    if actual != expected {
86        return Err(HttpError::ResponseTypeMismatch {
87            expected: expected.to_string(),
88            actual: actual.to_string(),
89        });
90    }
91
92    // Extract and return the data field.
93    let data = json_response
94        .get("data")
95        .ok_or(HttpError::MissingDataField)?;
96
97    serde_json::from_value(data.clone()).map_err(HttpError::JsonError)
98}
99
100/// Create a reqwest client with optional TLS configuration.
101fn client_builder(config: Option<&crate::config::TLSConfig>) -> HttpResult<reqwest::ClientBuilder> {
102    let builder = reqwest::Client::builder();
103    let Some(tls_config) = config.as_ref() else {
104        return Ok(builder);
105    };
106
107    #[cfg(not(any(feature = "http-tls-rustls", feature = "http-tls-native")))]
108    {
109        let _ = tls_config; // Suppress unused variable warning
110        Err(HttpError::TLSError(
111            "TLS configuration provided but no TLS features enabled. Enable either 'http-tls-rustls' or 'http-tls-native' feature".to_string()
112        ))
113    }
114
115    #[cfg(any(feature = "http-tls-rustls", feature = "http-tls-native"))]
116    {
117        let mut builder = builder;
118
119        // Configure TLS backend
120        #[cfg(feature = "http-tls-rustls")]
121        {
122            builder = builder.use_rustls_tls();
123        }
124
125        #[cfg(feature = "http-tls-native")]
126        {
127            builder = builder.use_native_tls();
128        }
129
130        // Load and add certificate
131        let certificate = load_certificate(&tls_config.certificate)?;
132        Ok(builder.add_root_certificate(certificate))
133    }
134}
135
136/// Load a certificate filepath, returning the certificate set for builder.
137#[cfg(any(feature = "http-tls-rustls", feature = "http-tls-native"))]
138fn load_certificate(cert_path: &std::path::Path) -> HttpResult<reqwest::tls::Certificate> {
139    let cert_data = std::fs::read(cert_path).map_err(HttpError::IOError)?;
140
141    // Try to parse based on file extension first
142    if let Some(ext) = cert_path.extension().and_then(|s| s.to_str()) {
143        match ext {
144            "pem" => return Ok(reqwest::tls::Certificate::from_pem(&cert_data)?),
145            "der" => return Ok(reqwest::tls::Certificate::from_der(&cert_data)?),
146            "crt" => {
147                if cert_data.starts_with(b"-----BEGIN") {
148                    return Ok(reqwest::tls::Certificate::from_pem(&cert_data)?);
149                } else {
150                    return Ok(reqwest::tls::Certificate::from_der(&cert_data)?);
151                }
152            }
153            _ => {} // Fall through to auto-detection
154        }
155    }
156
157    // Auto-detect format: try PEM first, then DER
158    reqwest::tls::Certificate::from_pem(&cert_data)
159        .or_else(|_| reqwest::tls::Certificate::from_der(&cert_data))
160        .map_err(Into::into)
161}
162
163/// SMS-API HTTP interface client.
164#[derive(Debug)]
165pub struct HttpClient {
166    base_url: reqwest::Url,
167    authorization: Option<String>,
168    modem_timeout: Option<std::time::Duration>,
169    client: reqwest::Client,
170}
171impl HttpClient {
172    /// Create a new HTTP client that uses the `base_url`.
173    pub fn new(
174        config: crate::config::HttpConfig,
175        tls: Option<&crate::config::TLSConfig>,
176    ) -> HttpResult<Self> {
177        let client = client_builder(tls)?.timeout(config.base_timeout).build()?;
178
179        Ok(Self {
180            base_url: reqwest::Url::parse(config.url.as_str())?,
181            authorization: config.authorization,
182            modem_timeout: config.modem_timeout,
183            client,
184        })
185    }
186
187    /// Set/Remove the friendly name for a given phone number.
188    pub async fn set_friendly_name(
189        &self,
190        phone_number: impl Into<String>,
191        friendly_name: Option<impl Into<String>>,
192    ) -> HttpResult<bool> {
193        let body = serde_json::json!({
194            "phone_number": phone_number.into(),
195            "friendly_name": friendly_name.map(Into::into)
196        });
197
198        let url = self.base_url.join("/db/friendly-names/set")?;
199        let response = self
200            .setup_request(false, self.client.post(url))
201            .json(&body)
202            .send()
203            .await?;
204
205        read_http_response(response).await
206    }
207
208    /// Get the friendly name associated with a given phone number.
209    pub async fn get_friendly_name(
210        &self,
211        phone_number: impl Into<String>,
212    ) -> HttpResult<Option<String>> {
213        let body = serde_json::json!({
214            "phone_number": phone_number.into()
215        });
216
217        let url = self.base_url.join("/db/friendly-names/get")?;
218        let response = self
219            .setup_request(false, self.client.post(url))
220            .json(&body)
221            .send()
222            .await?;
223
224        read_http_response(response).await
225    }
226
227    /// Get messages sent to and from a given phone number.
228    /// Pagination options are supported.
229    pub async fn get_messages(
230        &self,
231        phone_number: impl Into<String>,
232        pagination: Option<HttpPaginationOptions>,
233    ) -> HttpResult<Vec<crate::types::SmsStoredMessage>> {
234        let mut body = serde_json::json!({
235            "phone_number": phone_number.into()
236        });
237        if let Some(pagination) = pagination {
238            pagination.add_to_body(&mut body);
239        }
240
241        let url = self.base_url.join("/db/sms")?;
242        let response = self
243            .setup_request(false, self.client.post(url))
244            .json(&body)
245            .send()
246            .await?;
247
248        read_http_response(response).await
249    }
250
251    /// Get the latest phone numbers that have been in contact with the SMS-API.
252    /// This includes both senders and receivers. Pagination options are supported.
253    pub async fn get_latest_numbers(
254        &self,
255        pagination: Option<HttpPaginationOptions>,
256    ) -> HttpResult<Vec<LatestNumberFriendlyNamePair>> {
257        let url = self.base_url.join("/db/latest-numbers")?;
258        let mut request = self.setup_request(false, self.client.post(url));
259
260        // Only add a JSON body if there are pagination options.
261        if let Some(pagination) = pagination {
262            request = request.json(&pagination);
263        }
264
265        let response = request.send().await?;
266        read_http_response(response).await
267    }
268
269    /// Get received delivery reports for a given `message_id` (comes from `send_sms` etc).
270    /// Pagination options are supported.
271    pub async fn get_delivery_reports(
272        &self,
273        message_id: i64,
274        pagination: Option<HttpPaginationOptions>,
275    ) -> HttpResult<Vec<HttpSmsDeliveryReport>> {
276        let mut body = serde_json::json!({
277            "message_id": message_id
278        });
279        if let Some(pagination) = pagination {
280            pagination.add_to_body(&mut body);
281        }
282
283        let url = self.base_url.join("/db/delivery-reports")?;
284        let response = self
285            .setup_request(false, self.client.post(url))
286            .json(&body)
287            .send()
288            .await?;
289
290        read_http_response(response).await
291    }
292
293    /// Send an SMS message to a target `phone_number`. The result will contain the
294    /// message reference (provided from modem) and message id (used internally).
295    /// This will use the message timeout for the request if one is set.
296    pub async fn send_sms(
297        &self,
298        message: &HttpOutgoingSmsMessage,
299    ) -> HttpResult<HttpSmsSendResponse> {
300        let url = self.base_url.join("/sms/send")?;
301
302        // Create request, applying request timeout if one is set (+ 5).
303        // The timeout is enforced by the server, so the additional buffer is to allow for slow networking.
304        let mut request = self.setup_request(true, self.client.post(url));
305        if let Some(timeout) = message.timeout {
306            request = request.timeout(std::time::Duration::from_secs(u64::from(timeout) + 5));
307        }
308
309        let response = request.json(message).send().await?;
310
311        read_http_response(response).await
312    }
313
314    /// Get the carrier network status.
315    pub async fn get_network_status(&self) -> HttpResult<HttpModemNetworkStatusResponse> {
316        self.modem_request("modem-status", "NetworkStatus").await
317    }
318
319    /// Get the modem signal strength for the connected tower.
320    pub async fn get_signal_strength(&self) -> HttpResult<HttpModemSignalStrengthResponse> {
321        self.modem_request("signal-strength", "SignalStrength")
322            .await
323    }
324
325    /// Get the underlying network operator, this is often the same across
326    /// multiple service providers for a given region. Eg: vodafone.
327    pub async fn get_network_operator(&self) -> HttpResult<HttpModemNetworkOperatorResponse> {
328        self.modem_request("network-operator", "NetworkOperator")
329            .await
330    }
331
332    /// Get the SIM service provider, this is the brand that manages the contract.
333    /// This matters less than the network operator, as they're just resellers. Eg: ASDA Mobile.
334    pub async fn get_service_provider(&self) -> HttpResult<String> {
335        self.modem_request("service-provider", "ServiceProvider")
336            .await
337    }
338
339    /// Get the Modem Hat's battery level, which is used for GNSS warm starts.
340    pub async fn get_battery_level(&self) -> HttpResult<HttpModemBatteryLevelResponse> {
341        self.modem_request("battery-level", "BatteryLevel").await
342    }
343
344    /// Get device info summary result. This is a more efficient way to request all device info.
345    pub async fn get_device_info(&self) -> HttpResult<HttpSmsDeviceInfoData> {
346        let url = self.base_url.join("/sms/device-info")?;
347        let response = self
348            .setup_request(true, self.client.get(url))
349            .send()
350            .await?;
351
352        let response = read_http_response::<HttpSmsDeviceInfoResponse>(response).await?;
353        Ok(HttpSmsDeviceInfoData::from(response))
354    }
355
356    /// Get the configured sender SMS number. This should be used primarily for client identification.
357    /// This is optional, as the API could have left this un-configured without any value set.
358    pub async fn get_phone_number(&self) -> HttpResult<Option<String>> {
359        let url = self.base_url.join("/sys/phone-number")?;
360        let response = self
361            .setup_request(false, self.client.get(url))
362            .send()
363            .await?;
364
365        read_http_response(response).await
366    }
367
368    /// Get the modem SMS-API version string. This will be a semver format,
369    /// often with feature names added as a suffix, eg: "0.0.1+sentry".
370    pub async fn get_version(&self) -> HttpResult<String> {
371        let url = self.base_url.join("/sys/version")?;
372        let response = self
373            .setup_request(false, self.client.get(url))
374            .send()
375            .await?;
376
377        read_http_response(response).await
378    }
379
380    /// Send an SMS modem request, the response contains a named type which is verified.
381    async fn modem_request<T>(&self, route: &str, expected: &str) -> HttpResult<T>
382    where
383        T: serde::de::DeserializeOwned,
384    {
385        let url = self.base_url.join(&format!("/sms/{route}"))?;
386        let response = self
387            .setup_request(true, self.client.get(url))
388            .send()
389            .await?;
390
391        read_modem_response::<T>(expected, response).await
392    }
393
394    /// Allow for a different timeout to be used for modem requests,
395    /// and apply optional authorization header to request builder.
396    fn setup_request(
397        &self,
398        is_modem: bool,
399        builder: reqwest::RequestBuilder,
400    ) -> reqwest::RequestBuilder {
401        let builder = if is_modem && let Some(timeout) = &self.modem_timeout {
402            builder.timeout(*timeout)
403        } else {
404            builder
405        };
406        if let Some(auth) = &self.authorization {
407            builder.header("authorization", auth)
408        } else {
409            builder
410        }
411    }
412}