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, pub message: String, pub more_info: Option, } 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 { 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 { .. }))); } }