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