use serde::Deserialize;
use std::collections::HashMap;
use std::fmt::{self, Display, Formatter};
#[derive(Debug, Clone)]
pub struct TwilioClient<'a> {
// the HTTP client
pub client: reqwest::blocking::Client,
// the base URL
pub base_url: reqwest::Url,
// account SID
pub account_sid: &'a String,
// authentication token
pub auth_token: &'a String,
}
const TWILIO_API_PATH: &str = "/2010-04-01/Accounts";
// list of possible values: https://www.twilio.com/docs/sms/api/message-resource#message-status-values
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
#[allow(non_camel_case_types)]
pub enum MessageStatus {
accepted,
scheduled,
queued,
sending,
sent,
receiving,
received,
delivered,
undelivered,
failed,
read,
canceled,
}
impl fmt::Display for MessageStatus {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
// response from twilio's API
#[derive(Deserialize, Debug, Clone)]
pub struct Message {
pub status: MessageStatus,
}
// error from twilio's API
#[derive(Deserialize, Debug, Clone)]
pub struct Error {
pub status: u16,
pub code: Option<u16>,
pub message: String,
pub more_info: Option<String>,
}
impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "({}) {}", self.status, self.message)?;
Ok(())
}
}
impl<'a> TwilioClient<'a> {
pub fn new(account_sid: &'a String, auth_token: &'a String, base_url: reqwest::Url) -> Self {
let client = reqwest::blocking::Client::builder()
.user_agent("fcuny/send-sms")
.build()
.unwrap();
Self {
client,
base_url,
account_sid,
auth_token,
}
}
// send an SMS through twilio
pub fn send(&self, message: crate::message::Message) -> Result<Message, Error> {
let path = format!("{}/{}/Messages.json", TWILIO_API_PATH, self.account_sid);
let mut url = self.base_url.clone();
url.set_path(&path);
let mut form = HashMap::new();
form.insert("From", message.from);
form.insert("To", message.to);
form.insert("Body", message.body);
let response = self
.client
.post(url.as_str())
.basic_auth(self.account_sid, Some(self.auth_token))
.form(&form)
.send()
.unwrap();
match response.status() {
reqwest::StatusCode::CREATED | reqwest::StatusCode::OK => {}
_ => {
let error_response: Error = response.json().unwrap();
return Err(error_response);
}
};
Ok(response.json().unwrap())
}
}
#[cfg(test)]
mod test {
use crate::client::TwilioClient;
use crate::message::Message;
use httpmock::prelude::*;
use serde_json::json;
fn get_message() -> Message {
Message {
to: Some("1234".to_string()),
from: Some("1234".to_string()),
body: Some("test".to_string()),
}
}
#[test]
fn test_send_message() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(POST)
.path("/2010-04-01/Accounts/test/Messages.json");
then.status(200).json_body(json!({"status": "queued"}));
});
let message = get_message();
let account_sid = "test".to_string();
let auth_token = "test".to_string();
let client = TwilioClient::new(
&account_sid,
&auth_token,
reqwest::Url::parse(&server.base_url()).unwrap(),
);
let result = client.send(message);
mock.assert();
let msg: crate::client::Message = result.unwrap();
assert_eq!(msg.status, crate::client::MessageStatus::queued);
}
#[test]
fn test_send_message_error() {
let server = MockServer::start();
let mock_invalid = server.mock(|when, then| {
when.method(POST)
.path("/2010-04-01/Accounts/test/Messages.json");
then.status(400).json_body(json!({
"status": 400,
"message": "The 'From' number +15005550001 is not a valid phone number, shortcode, or alphanumeric sender ID",
}));
});
let message = get_message();
let account_sid = "test".to_string();
let auth_token = "test".to_string();
let client = TwilioClient::new(
&account_sid,
&auth_token,
reqwest::Url::parse(&server.base_url()).unwrap(),
);
let result = client.send(message);
mock_invalid.assert();
assert!(matches!(result, Err(crate::client::Error { .. })));
}
}