Skip to main content

android_sms_gateway/
client.rs

1use base64::Engine;
2use chrono::{DateTime, Utc};
3use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
4use reqwest::Method;
5
6use crate::config::ClientConfig;
7use crate::http::HttpTransport;
8use crate::types::*;
9use crate::Error;
10
11/// Client for the SMSGate API.
12///
13/// Provides methods for all API endpoints including messages, devices,
14/// settings, webhooks, authentication, inbox, and logs.
15///
16/// ## Example
17///
18/// ```no_run
19/// use android_sms_gateway::{
20///     Client, ClientConfig,
21///     types::{Message, SendOptions, TextMessage},
22/// };
23///
24/// # async fn example() -> Result<(), android_sms_gateway::Error> {
25/// let client = Client::new(
26///     ClientConfig::new().with_token("your-jwt-token")
27/// )?;
28///
29/// // Check service health
30/// let health = client.check_health().await?;
31/// println!("Status: {:?}", health.status);
32///
33/// // Send a text message
34/// let message = Message {
35///     phone_numbers: vec!["+1234567890".into()],
36///     text_message: Some(TextMessage { text: "Hello!".into() }),
37///     ..Default::default()
38/// };
39/// let state = client.send(&message, &SendOptions::new()).await?;
40/// println!("Message ID: {}", state.id);
41/// # Ok(())
42/// # }
43/// ```
44pub struct Client {
45    transport: HttpTransport,
46}
47
48impl Client {
49    /// Creates a new API client.
50    ///
51    /// Validates the configuration and initializes the HTTP transport.
52    pub fn new(config: ClientConfig) -> Result<Self, Error> {
53        config.validate()?;
54
55        let auth_header = match &config.token {
56            Some(token) => format!("Bearer {}", token),
57            None => {
58                let credentials = format!(
59                    "{}:{}",
60                    config.username.as_deref().unwrap_or(""),
61                    config.password.as_deref().unwrap_or("")
62                );
63                let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
64                format!("Basic {}", encoded)
65            }
66        };
67
68        let http_client = config.http_client.map(Ok).unwrap_or_else(|| {
69            reqwest::Client::builder()
70                .timeout(std::time::Duration::from_secs(30))
71                .build()
72        })?;
73        let user_agent = format!("android-sms-gateway/{} (client; rust)", crate::VERSION);
74
75        Ok(Self {
76            transport: HttpTransport::new(http_client, config.base_url, auth_header, user_agent),
77        })
78    }
79
80    /// Checks the service health status.
81    pub async fn check_health(&self) -> Result<HealthResponse, Error> {
82        self.transport
83            .request_json::<(), HealthResponse>(Method::GET, "/health", None)
84            .await
85    }
86
87    /// Sends a new message.
88    ///
89    /// See [`SendOptions`] for available options like skip phone validation
90    /// and device active within filter.
91    pub async fn send(
92        &self,
93        message: &Message,
94        options: &SendOptions,
95    ) -> Result<MessageState, Error> {
96        message.validate()?;
97        let query = options.to_url_query();
98        let path = build_path("/messages", &query);
99
100        self.transport
101            .request_json(Method::POST, &path, Some(message))
102            .await
103    }
104
105    /// Lists messages with optional filtering, pagination, and sorting.
106    ///
107    /// Returns a tuple of `(messages, total_count)`. The total count is
108    /// read from the `X-Total-Count` response header. Returns `None` for
109    /// the total when the header is missing or contains an unparseable
110    /// value.
111    pub async fn list_messages(
112        &self,
113        options: &ListMessagesOptions,
114    ) -> Result<(Vec<MessageState>, Option<u64>), Error> {
115        options.validate()?;
116        let query = options.to_url_query();
117        let path = build_path("/messages", &query);
118
119        let (results, headers): (Vec<MessageState>, _) = self
120            .transport
121            .request_json_with_headers(Method::GET, &path, None::<&()>)
122            .await?;
123
124        let total = headers
125            .get("X-Total-Count")
126            .and_then(|v| v.to_str().ok())
127            .and_then(|v| v.parse::<u64>().ok());
128
129        Ok((results, total))
130    }
131
132    /// Gets the current state of a message by its ID.
133    pub async fn get_message_state(&self, id: &str) -> Result<MessageState, Error> {
134        let path = format!("/messages/{}", encode_path_segment(id));
135        self.transport
136            .request_json::<(), MessageState>(Method::GET, &path, None)
137            .await
138    }
139
140    /// Cancels a pending message by its ID.
141    pub async fn cancel_message(&self, id: &str) -> Result<(), Error> {
142        let path = format!("/messages/{}", encode_path_segment(id));
143        self.transport
144            .request_empty::<()>(Method::DELETE, &path, None)
145            .await
146    }
147
148    /// Lists all registered devices.
149    pub async fn list_devices(&self) -> Result<Vec<Device>, Error> {
150        self.transport
151            .request_json::<(), Vec<Device>>(Method::GET, "/devices", None)
152            .await
153    }
154
155    /// Removes a device by its ID.
156    pub async fn delete_device(&self, id: &str) -> Result<(), Error> {
157        let path = format!("/devices/{}", encode_path_segment(id));
158        self.transport
159            .request_empty::<()>(Method::DELETE, &path, None)
160            .await
161    }
162
163    /// Gets the current device settings.
164    pub async fn get_settings(&self) -> Result<DeviceSettings, Error> {
165        self.transport
166            .request_json::<(), DeviceSettings>(Method::GET, "/settings", None)
167            .await
168    }
169
170    /// Replaces all settings.
171    pub async fn replace_settings(
172        &self,
173        settings: &DeviceSettings,
174    ) -> Result<DeviceSettings, Error> {
175        settings.validate()?;
176        self.transport
177            .request_json(Method::PUT, "/settings", Some(settings))
178            .await
179    }
180
181    /// Partially updates settings.
182    pub async fn update_settings(
183        &self,
184        settings: &DeviceSettings,
185    ) -> Result<DeviceSettings, Error> {
186        settings.validate()?;
187        self.transport
188            .request_json(Method::PATCH, "/settings", Some(settings))
189            .await
190    }
191
192    /// Lists all registered webhooks.
193    pub async fn list_webhooks(&self) -> Result<Vec<Webhook>, Error> {
194        self.transport
195            .request_json::<(), Vec<Webhook>>(Method::GET, "/webhooks", None)
196            .await
197    }
198
199    /// Registers a new webhook.
200    pub async fn register_webhook(&self, webhook: &Webhook) -> Result<Webhook, Error> {
201        webhook.validate()?;
202        self.transport
203            .request_json(Method::POST, "/webhooks", Some(webhook))
204            .await
205    }
206
207    /// Deletes a webhook by its ID.
208    pub async fn delete_webhook(&self, id: &str) -> Result<(), Error> {
209        let path = format!("/webhooks/{}", encode_path_segment(id));
210        self.transport
211            .request_empty::<()>(Method::DELETE, &path, None)
212            .await
213    }
214
215    /// Generates a new JWT token with the specified scopes and TTL.
216    pub async fn generate_token(&self, request: &TokenRequest) -> Result<TokenResponse, Error> {
217        self.transport
218            .request_json(Method::POST, "/auth/token", Some(request))
219            .await
220    }
221
222    /// Refreshes an existing JWT token using its refresh token.
223    ///
224    /// The refresh token is sent as a Bearer token in the Authorization header.
225    pub async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResponse, Error> {
226        let auth_header = format!("Bearer {}", refresh_token);
227        self.transport
228            .request_json_custom_auth::<(), TokenResponse>(
229                Method::POST,
230                "/auth/token/refresh",
231                None,
232                &auth_header,
233            )
234            .await
235    }
236
237    /// Revokes a JWT token by its ID (JTI).
238    pub async fn revoke_token(&self, jti: &str) -> Result<(), Error> {
239        let path = format!("/auth/token/{}", encode_path_segment(jti));
240        self.transport
241            .request_empty::<()>(Method::DELETE, &path, None)
242            .await
243    }
244
245    /// Requests an inbox refresh to pull new messages from the device.
246    pub async fn refresh_inbox(&self, request: &InboxRefreshRequest) -> Result<(), Error> {
247        self.transport
248            .request_empty(Method::POST, "/inbox/refresh", Some(request))
249            .await
250    }
251
252    /// Lists inbox messages with filtering and pagination.
253    ///
254    /// Returns a tuple of `(messages, total_count)`. Returns `None` for
255    /// the total when the `X-Total-Count` header is missing or contains
256    /// an unparseable value.
257    pub async fn list_inbox_messages(
258        &self,
259        options: &ListInboxOptions,
260    ) -> Result<(Vec<IncomingMessage>, Option<u64>), Error> {
261        options.validate()?;
262        let query = options.to_url_query();
263        let path = build_path("/inbox", &query);
264
265        let (results, headers): (Vec<IncomingMessage>, _) = self
266            .transport
267            .request_json_with_headers(Method::GET, &path, None::<&()>)
268            .await?;
269
270        let total = headers
271            .get("X-Total-Count")
272            .and_then(|v| v.to_str().ok())
273            .and_then(|v| v.parse::<u64>().ok());
274
275        Ok((results, total))
276    }
277
278    /// Retrieves log entries within a time range.
279    pub async fn get_logs(
280        &self,
281        from: &DateTime<Utc>,
282        to: &DateTime<Utc>,
283    ) -> Result<Vec<LogEntry>, Error> {
284        if from > to {
285            return Err(Error::Validation(
286                "`from` date must be before `to` date".to_string(),
287            ));
288        }
289        let path = format!(
290            "/logs?from={}&to={}",
291            encode_path_segment(&from.to_rfc3339()),
292            encode_path_segment(&to.to_rfc3339())
293        );
294        self.transport
295            .request_json::<(), Vec<LogEntry>>(Method::GET, &path, None)
296            .await
297    }
298
299    /// Exports inbox messages via webhooks.
300    pub async fn export_inbox(&self, request: &MessagesExportRequest) -> Result<(), Error> {
301        self.transport
302            .request_empty(Method::POST, "/inbox/export", Some(request))
303            .await
304    }
305
306    /// Downloads a specific MMS attachment by message ID and part ID.
307    ///
308    /// Returns the raw attachment bytes (e.g. image, audio, video).
309    pub async fn download_attachment(
310        &self,
311        message_id: &str,
312        part_id: i32,
313    ) -> Result<Vec<u8>, Error> {
314        let path = format!(
315            "/inbox/{}/attachments/{}",
316            encode_path_segment(message_id),
317            part_id
318        );
319        self.transport
320            .request_bytes::<()>(Method::GET, &path, None)
321            .await
322    }
323}
324
325fn encode_path_segment(s: &str) -> String {
326    const PATH_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC
327        .remove(b'-')
328        .remove(b'.')
329        .remove(b'_')
330        .remove(b'~');
331    utf8_percent_encode(s, PATH_SEGMENT).to_string()
332}
333
334fn build_path(base: &str, query: &str) -> String {
335    if query.is_empty() {
336        base.to_string()
337    } else {
338        format!("{}?{}", base, query)
339    }
340}