diff options
| author | Franck Cuny <franck@fcuny.net> | 2022-11-19 18:48:12 -0800 |
|---|---|---|
| committer | Franck Cuny <franck@fcuny.net> | 2022-11-19 19:01:01 -0800 |
| commit | d78070d130c32227a2788591d3efd321da702658 (patch) | |
| tree | 55e34a15a8ff3b53714c55974bd738e48a5bbcb9 /src | |
| parent | meta: ignore artifact created by `nix build` (diff) | |
| download | sendsms-d78070d130c32227a2788591d3efd321da702658.tar.gz | |
feat: a CLI to send sms via twilio's API
I want to get an SMS when some of my machines are rebooted. This tool
let me do just that. The CLI takes as argument the path to a
configuration file that contains credentials to the API. It also has a
number of sub commands, and each sub command is associated with a
specific action.
For each action, a section in the configuration file is expected. The
two actions currently supported are:
- `reboot`
- `hello`
`hello` is a simple one used for testing, it sends "hello world".
`reboot` is used to create a message that contains the host's name and
the IP address of the network interface specified in the configuration
file.
Diffstat (limited to 'src')
| -rw-r--r-- | src/client.rs | 178 | ||||
| -rw-r--r-- | src/config.rs | 27 | ||||
| -rwxr-xr-x[-rw-r--r--] | src/main.rs | 51 | ||||
| -rwxr-xr-x | src/message.rs | 118 |
4 files changed, 373 insertions, 1 deletions
diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..2e38ba9 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,178 @@ +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 { .. }))); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..29145b8 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,27 @@ +use serde::Deserialize; +use std::path::PathBuf; + +#[derive(Deserialize, Debug)] +pub struct Config { + pub to: String, + pub from: String, + pub account_sid: String, + pub auth_token: String, + pub reboot: RebootConfig, + pub hello: HelloConfig, +} + +#[derive(Deserialize, Debug)] +pub struct RebootConfig { + pub ifname: String, +} + +#[derive(Deserialize, Debug)] +pub struct HelloConfig {} + +impl Config { + pub fn load_from_file(filename: &PathBuf) -> std::io::Result<Config> { + let content = std::fs::read_to_string(filename)?; + Ok(toml::from_str(&content)?) + } +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..f6e4a37 100644..100755 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,52 @@ +mod client; +mod config; +mod message; + +use clap::{crate_version, Parser}; +use std::path::PathBuf; +use std::process::exit; + +#[derive(Parser, Debug)] +#[clap(author, version, about, long_about = None)] +#[clap(version = crate_version!())] +struct Args { + #[clap(short, long, value_parser)] + config: PathBuf, + + #[clap(subcommand)] + subcmd: message::MessageKind, +} + +const TWILIO_BASE_URL: &str = "https://api.twilio.com"; + fn main() { - println!("Hello, world!"); + let args = Args::parse(); + + let config: config::Config = match config::Config::load_from_file(&args.config) { + Ok(config) => config, + Err(e) => { + eprintln!("unable to load data from {}: {}", args.config.display(), e); + exit(1); + } + }; + + let msg = message::Message::builder() + .from(config.from.to_owned()) + .to(config.to.to_owned()) + .with_action(args.subcmd, &config) + .build(); + + let client = client::TwilioClient::new( + &config.account_sid, + &config.auth_token, + reqwest::Url::parse(TWILIO_BASE_URL).unwrap(), + ); + + match client.send(msg) { + Ok(m) => println!("message sent successfully: status is {:?}", m.status), + Err(error) => { + eprintln!("failed to send the message: {}", error); + exit(1); + } + } } diff --git a/src/message.rs b/src/message.rs new file mode 100755 index 0000000..5fbd5fa --- /dev/null +++ b/src/message.rs @@ -0,0 +1,118 @@ +use clap::Parser; +use gethostname::gethostname; +use serde::Deserialize; +use std::net::IpAddr; + +#[derive(Parser, Debug)] +pub enum MessageKind { + Hello, + Reboot, +} + +#[derive(Deserialize, Debug)] +pub struct MessageBuilder { + pub from: Option<String>, + pub to: Option<String>, + pub body: Option<String>, +} + +impl MessageBuilder { + fn new() -> Self { + Self { + from: None, + to: None, + body: None, + } + } + + pub fn from(mut self, from: impl Into<String>) -> Self { + self.from = Some(from.into()); + self + } + + pub fn to(mut self, to: impl Into<String>) -> Self { + self.to = Some(to.into()); + self + } + + pub fn with_action(mut self, action: MessageKind, config: &crate::config::Config) -> Self { + let body = match action { + MessageKind::Reboot => reboot(&config.reboot), + MessageKind::Hello => hello(), + }; + self.body = Some(body); + self + } + + pub fn build(self) -> Message { + let Self { from, to, body } = self; + Message { from, to, body } + } +} + +#[derive(Deserialize, Debug)] +pub struct Message { + pub from: Option<String>, + pub to: Option<String>, + pub body: Option<String>, +} + +impl Message { + pub fn builder() -> MessageBuilder { + MessageBuilder::new() + } +} + +fn hello() -> String { + String::from("hello world") +} + +fn reboot(config: &crate::config::RebootConfig) -> String { + let ipaddr_v4 = if_addrs::get_if_addrs() + .unwrap_or_default() + .into_iter() + .find(|iface| iface.name == config.ifname) + .and_then(|iface| match iface.ip() { + IpAddr::V4(addr) => Some(addr), + IpAddr::V6(_) => None, + }) + .expect("there should be an ipv4 address"); + + let hostname = gethostname() + .into_string() + .expect("failed to get the hostname"); + + format!( + "{} has rebooted. The IP address for the interface {} is {}.", + hostname, config.ifname, ipaddr_v4 + ) +} + +#[cfg(test)] +mod test { + use crate::message::Message; + + #[test] + fn test_create_message() { + let from = "1".to_string(); + let to = "2".to_string(); + let account_sid = "test".to_string(); + let auth_token = "test".to_string(); + let cfg = crate::config::Config { + to, + from, + account_sid, + auth_token, + reboot: crate::config::RebootConfig { + ifname: "eth0".to_string(), + }, + hello: crate::config::HelloConfig {}, + }; + let msg = Message::builder() + .from("1") + .to("2") + .with_action(crate::message::MessageKind::Hello, &cfg) + .build(); + assert_eq!(msg.body, Some("hello world".to_string())); + } +} |
