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::gnss::{FixStatus, PositionReport};
6use sms_types::http::{
7    HttpModemBatteryLevelResponse, HttpModemNetworkOperatorResponse,
8    HttpModemNetworkStatusResponse, HttpModemSignalStrengthResponse, HttpPaginationOptions,
9    HttpSmsDeviceInfoData, HttpSmsDeviceInfoResponse, HttpSmsSendResponse,
10    LatestNumberFriendlyNamePair,
11};
12use sms_types::sms::{SmsDeliveryReport, SmsOutgoingMessage};
13
14pub mod error;
15pub mod paginator;
16
17/// Take a response from the client, verify that the status code is 200,
18/// then read JSON body and ensure success is true and finally return response value.
19async fn read_http_response<T>(response: reqwest::Response) -> HttpResult<T>
20where
21    T: serde::de::DeserializeOwned,
22{
23    let is_json = response
24        .headers()
25        .get(reqwest::header::CONTENT_TYPE)
26        .and_then(|ct| ct.to_str().ok())
27        .is_some_and(|ct| ct.contains("application/json"));
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<sms_types::sms::SmsMessage>> {
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<SmsDeliveryReport>> {
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(&self, message: &SmsOutgoingMessage) -> HttpResult<HttpSmsSendResponse> {
297        let url = self.base_url.join("/sms/send")?;
298
299        // Create request, applying request timeout if one is set (+ 5).
300        // The timeout is enforced by the server, so the additional buffer is to allow for slow networking.
301        let mut request = self.setup_request(true, self.client.post(url));
302        if let Some(timeout) = message.timeout {
303            request = request.timeout(std::time::Duration::from_secs(u64::from(timeout) + 5));
304        }
305
306        let response = request.json(message).send().await?;
307
308        read_http_response(response).await
309    }
310
311    /// Get the carrier network status.
312    pub async fn get_network_status(&self) -> HttpResult<HttpModemNetworkStatusResponse> {
313        self.modem_request("/sms/modem-status", "NetworkStatus")
314            .await
315    }
316
317    /// Get the modem signal strength for the connected tower.
318    pub async fn get_signal_strength(&self) -> HttpResult<HttpModemSignalStrengthResponse> {
319        self.modem_request("/sms/signal-strength", "SignalStrength")
320            .await
321    }
322
323    /// Get the underlying network operator, this is often the same across
324    /// multiple service providers for a given region. Eg: vodafone.
325    pub async fn get_network_operator(&self) -> HttpResult<HttpModemNetworkOperatorResponse> {
326        self.modem_request("/sms/network-operator", "NetworkOperator")
327            .await
328    }
329
330    /// Get the SIM service provider, this is the brand that manages the contract.
331    /// This matters less than the network operator, as they're just resellers. Eg: ASDA Mobile.
332    pub async fn get_service_provider(&self) -> HttpResult<String> {
333        self.modem_request("/sms/service-provider", "ServiceProvider")
334            .await
335    }
336
337    /// Get the Modem Hat's battery level, which is used for GNSS warm starts.
338    pub async fn get_battery_level(&self) -> HttpResult<HttpModemBatteryLevelResponse> {
339        self.modem_request("/sms/battery-level", "BatteryLevel")
340            .await
341    }
342
343    /// Get the GNSS module's fix status, indicating location data capabilities.
344    /// If GNSS is disabled/unavailable this will likely be `FixStatus::Unknown`.
345    pub async fn get_gnss_status(&self) -> HttpResult<FixStatus> {
346        self.modem_request("/gnss/status", "GNSSStatus").await
347    }
348
349    /// Get the GNSS module's current location (`PositionReport`).
350    /// If GNSS is disabled/unavailable some values be None, others may be Some(0.00).
351    /// This depends on the SIM chip being used.
352    pub async fn get_gnss_location(&self) -> HttpResult<PositionReport> {
353        self.modem_request("/gnss/location", "GNSSLocation").await
354    }
355
356    /// Get device info summary result. This is a more efficient way to request all device info.
357    pub async fn get_device_info(&self) -> HttpResult<HttpSmsDeviceInfoData> {
358        let url = self.base_url.join("/sms/device-info")?;
359        let response = self
360            .setup_request(true, self.client.get(url))
361            .send()
362            .await?;
363
364        let response = read_http_response::<HttpSmsDeviceInfoResponse>(response).await?;
365        Ok(HttpSmsDeviceInfoData::from(response))
366    }
367
368    /// Get the configured sender SMS number. This should be used primarily for client identification.
369    /// This is optional, as the API could have left this un-configured without any value set.
370    pub async fn get_phone_number(&self) -> HttpResult<Option<String>> {
371        let url = self.base_url.join("/sys/phone-number")?;
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    /// Get the modem SMS-API version string. This will be a semver format,
381    /// often with feature names added as a suffix, eg: "0.0.1+sentry".
382    pub async fn get_version(&self) -> HttpResult<String> {
383        let url = self.base_url.join("/sys/version")?;
384        let response = self
385            .setup_request(false, self.client.get(url))
386            .send()
387            .await?;
388
389        read_http_response(response).await
390    }
391
392    /// Send an SMS modem request, the response contains a named type which is verified.
393    async fn modem_request<T>(&self, route: &str, expected: &str) -> HttpResult<T>
394    where
395        T: serde::de::DeserializeOwned,
396    {
397        let url = self.base_url.join(route)?;
398        let response = self
399            .setup_request(true, self.client.get(url))
400            .send()
401            .await?;
402
403        read_modem_response::<T>(expected, response).await
404    }
405
406    /// Allow for a different timeout to be used for modem requests,
407    /// and apply optional authorization header to request builder.
408    fn setup_request(
409        &self,
410        is_modem: bool,
411        builder: reqwest::RequestBuilder,
412    ) -> reqwest::RequestBuilder {
413        let builder = if is_modem && let Some(timeout) = &self.modem_timeout {
414            builder.timeout(*timeout)
415        } else {
416            builder
417        };
418        if let Some(auth) = &self.authorization {
419            builder.header("authorization", auth)
420        } else {
421            builder
422        }
423    }
424}