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