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