Skip to main content

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