Skip to main content

esteria_api_client/
esteria.rs

1use chrono::{DateTime, Utc};
2use reqwest::Client;
3use std::collections::HashMap;
4use thiserror::Error;
5
6/// Error types for SMS operations
7#[derive(Error, Debug)]
8pub enum SmsError {
9    #[error("SMS sending failed to: {number}, {message}")]
10    SendFailed { number: String, message: String },
11    #[error("HTTP request failed: {0}")]
12    RequestFailed(#[from] reqwest::Error),
13}
14
15bitflags::bitflags! {
16    /// Flags for SMS sending options
17    #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
18    pub struct SmsFlags: u32 {
19        /// Enable debug mode
20        const DEBUG   = 0b0000_0001;
21        /// Disable logging
22        const NOLOG   = 0b0000_0010;
23        /// Send as flash SMS
24        const FLASH   = 0b0000_0100;
25        /// Test mode (don't actually send)
26        const TEST    = 0b0000_1000;
27        /// No blacklist check
28        const NOBL    = 0b0001_0000;
29        /// Convert characters
30        const CONVERT = 0b0010_0000;
31    }
32}
33
34/// SMS encoding options
35#[derive(Debug, Clone, Copy)]
36pub enum Encoding {
37    /// Default encoding
38    Default,
39    /// 8-bit encoding
40    EightBit,
41    /// User Data Header encoding
42    Udh,
43}
44
45/// SMS API client for Esteria
46pub struct SmsClient {
47    api_base_url: String,
48    client: Client,
49}
50
51/// Request structure for sending SMS
52pub struct SmsRequest<'a> {
53    pub api_key: &'a str,
54    pub sender: &'a str,
55    pub number: &'a str,
56    pub text: &'a str,
57    pub time: Option<DateTime<Utc>>,
58    pub dlr_url: Option<&'a str>,
59    pub expired: Option<i32>,
60    pub flags: SmsFlags,
61    pub user_key: Option<&'a str>,
62    pub encoding: Encoding,
63}
64
65impl<'a> SmsRequest<'a> {
66    /// Create a new SMS request with required parameters
67    #[must_use]
68    pub fn new(api_key: &'a str, sender: &'a str, number: &'a str, text: &'a str) -> Self {
69        Self {
70            api_key,
71            sender,
72            number,
73            text,
74            time: None,
75            dlr_url: None,
76            expired: None,
77            flags: SmsFlags::empty(),
78            user_key: None,
79            encoding: Encoding::EightBit,
80        }
81    }
82
83    /// Set scheduled delivery time
84    #[must_use]
85    pub fn with_time(mut self, time: DateTime<Utc>) -> Self {
86        self.time = Some(time);
87        self
88    }
89
90    /// Set delivery report URL
91    #[must_use]
92    pub fn with_dlr_url(mut self, dlr_url: &'a str) -> Self {
93        self.dlr_url = Some(dlr_url);
94        self
95    }
96
97    /// Set expiration time in minutes
98    #[must_use]
99    pub fn with_expired(mut self, expired: i32) -> Self {
100        self.expired = Some(expired);
101        self
102    }
103
104    /// Set SMS flags
105    #[must_use]
106    pub fn with_flags(mut self, flags: SmsFlags) -> Self {
107        self.flags = flags;
108        self
109    }
110
111    /// Set user key for tracking
112    #[must_use]
113    pub fn with_user_key(mut self, user_key: &'a str) -> Self {
114        self.user_key = Some(user_key);
115        self
116    }
117
118    /// Set encoding
119    #[must_use]
120    pub fn with_encoding(mut self, encoding: Encoding) -> Self {
121        self.encoding = encoding;
122        self
123    }
124}
125
126impl Default for SmsClient {
127    fn default() -> Self {
128        Self::with_api_base_url("https://api.esteria.eu")
129    }
130}
131
132impl SmsClient {
133    /// Create a new SMS client using the default API base URL
134    #[allow(dead_code)]
135    #[must_use]
136    pub fn new() -> Self {
137        Self::default()
138    }
139
140    /// Create a new SMS client with a custom API base URL
141    #[must_use]
142    pub fn with_api_base_url(api_base_url: impl Into<String>) -> Self {
143        Self {
144            api_base_url: api_base_url.into(),
145            client: Client::new(),
146        }
147    }
148
149    /// Send an SMS message
150    ///
151    /// Returns the message ID on success (> 100)
152    ///
153    /// # Errors
154    ///
155    /// Returns `SmsError::SendFailed` if the API returns an error code (< 100)
156    /// or `SmsError::RequestFailed` if the HTTP request fails
157    pub async fn send_sms(&self, request: SmsRequest<'_>) -> Result<i32, SmsError> {
158        let mut params: HashMap<&str, String> = HashMap::new();
159
160        params.insert("api-key", request.api_key.to_string());
161        params.insert("sender", request.sender.to_string());
162        params.insert("number", request.number.trim_start_matches('+').to_string());
163        params.insert("text", request.text.to_string());
164
165        if let Some(time) = request.time {
166            params.insert("time", time.format("%Y-%m-%dT%H:%M:%S").to_string());
167        }
168
169        if let Some(dlr_url) = request.dlr_url {
170            params.insert("dlr-url", dlr_url.to_string());
171        }
172
173        if let Some(expired) = request.expired {
174            params.insert("expired", expired.to_string());
175        }
176
177        if request.flags.contains(SmsFlags::DEBUG) {
178            params.insert("flag-debug", "1".to_string());
179        }
180
181        if request.flags.contains(SmsFlags::NOLOG) {
182            params.insert("flag-nolog", "3".to_string());
183        }
184
185        if request.flags.contains(SmsFlags::FLASH) {
186            params.insert("flag-flash", "1".to_string());
187        }
188
189        if request.flags.contains(SmsFlags::TEST) {
190            params.insert("flag-test", "1".to_string());
191        }
192
193        if request.flags.contains(SmsFlags::NOBL) {
194            params.insert("flag-nobl", "1".to_string());
195        }
196
197        if request.flags.contains(SmsFlags::CONVERT) {
198            params.insert("flag-convert", "1".to_string());
199        }
200
201        if let Some(user_key) = request.user_key {
202            params.insert("user-key", user_key.to_string());
203        }
204
205        match request.encoding {
206            Encoding::Udh => {
207                params.insert("udh", "1".to_string());
208                params.insert("coding", "1".to_string());
209            }
210            Encoding::EightBit => {
211                params.insert("coding", "1".to_string());
212            }
213            Encoding::Default => {}
214        }
215
216        let url = format!("{}/send", self.api_base_url);
217        let response = self.client.get(&url).query(&params).send().await?;
218
219        let resp_text = response.text().await?;
220
221        let result = resp_text.trim().parse::<i32>().ok();
222
223        if let Some(code) = result {
224            if code > 100 {
225                return Ok(code);
226            }
227
228            let error_msg = get_response_code_message(code);
229            log::error!("SMS sending failed to: {}, {}", request.number, error_msg);
230
231            return Err(SmsError::SendFailed {
232                number: request.number.to_string(),
233                message: error_msg.to_string(),
234            });
235        }
236
237        log::error!("SMS sending failed to: {}, unknown error", request.number);
238        Err(SmsError::SendFailed {
239            number: request.number.to_string(),
240            message: "unknown error".to_string(),
241        })
242    }
243}
244
245fn get_response_code_message(code: i32) -> &'static str {
246    match code {
247        1 => "system internal error",
248        2 => "missing PARAM_NAME parameter",
249        3 => "unable to authenticate",
250        4 => "IP ADDRESS is not allowed",
251        5 => "invalid SENDER parameter",
252        6 => "SENDER is not allowed",
253        7 => "invalid NUMBER parameter",
254        8 => "invalid CODING parameter",
255        9 => "unable to convert TEXT",
256        10 => "length of UDH and TEXT too long",
257        11 => "empty TEXT parameter",
258        12 => "invalid TIME parameter",
259        13 => "invalid EXPIRED parameter",
260        14 => "invalid DLR-URL parameter",
261        15 => "Invalid FLAG-FLASH parameter",
262        16 => "invalid FLAG-NOLOG parameter",
263        17 => "invalid FLAG-TEST parameter",
264        18 => "invalid FLAG-NOBL parameter",
265        19 => "invalid FLAG-CONVERT parameter",
266        _ => "unknown error",
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use chrono::TimeZone;
274    use httpmock::prelude::*;
275
276    fn base_request<'a>() -> SmsRequest<'a> {
277        SmsRequest::new("k", "Alice", "+1234567890", "Hello")
278    }
279
280    #[tokio::test]
281    async fn send_sms_success_and_params() {
282        let server = MockServer::start();
283
284        // Build flags and encoding to verify query mapping
285        let mut flags = SmsFlags::empty();
286        flags |= SmsFlags::DEBUG
287            | SmsFlags::NOLOG
288            | SmsFlags::FLASH
289            | SmsFlags::TEST
290            | SmsFlags::NOBL
291            | SmsFlags::CONVERT;
292
293        let time = Utc.with_ymd_and_hms(2025, 1, 2, 3, 4, 5).unwrap();
294
295        let req = base_request()
296            .with_time(time)
297            .with_dlr_url("https://example.com/dlr")
298            .with_expired(60)
299            .with_flags(flags)
300            .with_user_key("ukey")
301            .with_encoding(Encoding::Udh);
302
303        // Expect all query params and return success code (>100)
304        let m = server.mock(|when, then| {
305            when.method(GET)
306                .path("/send")
307                .query_param("api-key", "k")
308                .query_param("sender", "Alice")
309                .query_param("number", "1234567890") // plus sign trimmed
310                .query_param("text", "Hello")
311                .query_param("time", "2025-01-02T03:04:05")
312                .query_param("dlr-url", "https://example.com/dlr")
313                .query_param("expired", "60")
314                .query_param("flag-debug", "1")
315                .query_param("flag-nolog", "3")
316                .query_param("flag-flash", "1")
317                .query_param("flag-test", "1")
318                .query_param("flag-nobl", "1")
319                .query_param("flag-convert", "1")
320                .query_param("user-key", "ukey")
321                .query_param("udh", "1")
322                .query_param("coding", "1");
323            then.status(200).body("1234");
324        });
325
326        let client = SmsClient::with_api_base_url(server.base_url());;
327        let code = client.send_sms(req).await.unwrap();
328        assert_eq!(code, 1234);
329        m.assert();
330    }
331
332    #[tokio::test]
333    async fn send_sms_api_error_mapped() {
334        let server = MockServer::start();
335        let m = server.mock(|when, then| {
336            when.method(GET).path("/send");
337            then.status(200).body("3"); // 3 => unable to authenticate
338        });
339
340        let client = SmsClient::with_api_base_url(server.base_url());;
341        let err = client.send_sms(base_request()).await.unwrap_err();
342        match err {
343            SmsError::SendFailed { number, message } => {
344                assert_eq!(number, "+1234567890");
345                assert_eq!(message, "unable to authenticate");
346            }
347            SmsError::RequestFailed(err) => panic!("Unexpected error type: {err}"),
348        }
349        m.assert();
350    }
351
352    #[tokio::test]
353    async fn send_sms_unknown_text_maps_to_unknown_error() {
354        let server = MockServer::start();
355        let m = server.mock(|when, then| {
356            when.method(GET).path("/send");
357            then.status(200).body("not-a-number");
358        });
359
360        let client = SmsClient::with_api_base_url(server.base_url());;
361        let err = client.send_sms(base_request()).await.unwrap_err();
362        match err {
363            SmsError::SendFailed { number, message } => {
364                assert_eq!(number, "+1234567890");
365                assert_eq!(message, "unknown error");
366            }
367            SmsError::RequestFailed(err) => panic!("Unexpected error type: {err}"),
368        }
369        m.assert();
370    }
371
372    #[tokio::test]
373    async fn send_sms_http_failure_is_request_failed() {
374        // Use a non-routable private address to provoke connection error
375        let client = SmsClient::with_api_base_url("http://10.255.255.1".to_string());
376        let err = client.send_sms(base_request()).await.unwrap_err();
377        matches!(err, SmsError::RequestFailed(_));
378    }
379
380    #[test]
381    fn builder_sets_fields_and_defaults() {
382        let req = SmsRequest::new("key", "S", "N", "T");
383        assert!(req.time.is_none());
384        assert!(req.dlr_url.is_none());
385        assert!(req.expired.is_none());
386        assert!(req.user_key.is_none());
387        assert_eq!(req.flags, SmsFlags::empty());
388        matches!(req.encoding, Encoding::EightBit);
389    }
390
391    #[test]
392    fn get_response_code_message_works() {
393        assert_eq!(get_response_code_message(1), "system internal error");
394        assert_eq!(
395            get_response_code_message(19),
396            "invalid FLAG-CONVERT parameter"
397        );
398        assert_eq!(get_response_code_message(999), "unknown error");
399    }
400}