hermod_api/clients/
twilio.rs

1//! Contains everything required for interacting with Twilio's API
2use reqwest::Client;
3
4/// Client for sending SMS messages and phone calls
5pub struct TwilioClient {
6    http_client: Client,
7    base_url: String,
8    account_sid: String,
9    auth_token: String,
10    from: String,
11}
12
13impl TwilioClient {
14    /// Create a new Twilio client
15    pub fn new(
16        base_url: String,
17        timeout: std::time::Duration,
18        account_sid: String,
19        auth_token: String,
20        from: String,
21    ) -> Self {
22        let http_client = Client::builder().timeout(timeout).build().unwrap();
23        Self {
24            http_client,
25            base_url,
26            account_sid,
27            auth_token,
28            from,
29        }
30    }
31
32    /// Send an SMS message using Twilio's API
33    #[tracing::instrument(name = "clients::twilio::send_sms", skip(self))]
34    pub async fn send_sms(&self, to: String, message: String) -> Result<(), reqwest::Error> {
35        let url = format!("{}Accounts/{}/Messages", self.base_url, &self.account_sid);
36        let message = urlencoding::encode(&message);
37        let body = format!("Body={}&To={}&From={}", message, to, self.from);
38
39        self.http_client
40            .post(&url)
41            .header("Content-Type", "application/x-www-form-urlencoded")
42            .basic_auth(&self.account_sid, Some(&self.auth_token))
43            .body(body)
44            .send()
45            .await?
46            .error_for_status()?;
47        Ok(())
48    }
49
50    /// Send a phone call using Twilio's API
51    #[tracing::instrument(name = "clients::twilio::send_call", skip(self))]
52    pub async fn send_call(&self, to: String, message: String) -> Result<(), reqwest::Error> {
53        let url = format!("{}Accounts/{}/Calls.json", self.base_url, &self.account_sid);
54        let message = urlencoding::encode(&message);
55        let twiml = format!("<Response><Say>{}</Say></Response>", message);
56        let body = format!("Twiml={}&To={}&From={}", twiml, to, self.from);
57
58        self.http_client
59            .post(&url)
60            .header("Content-Type", "application/x-www-form-urlencoded")
61            .basic_auth(&self.account_sid, Some(&self.auth_token))
62            .body(body)
63            .send()
64            .await?
65            .error_for_status()?;
66        Ok(())
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use std::time::Duration;
73
74    use wiremock::{
75        matchers::{header, method, path},
76        Mock, MockServer, Request, ResponseTemplate,
77    };
78
79    use super::TwilioClient;
80
81    struct SendSmsBodyMatcher;
82
83    impl wiremock::Match for SendSmsBodyMatcher {
84        fn matches(&self, request: &Request) -> bool {
85            let result = String::from_utf8(request.body.clone());
86            if let Ok(body) = result {
87                body.contains(&format!("Body={}", urlencoding::encode("Hello, World!")))
88                    && body.contains(&format!("To={}", "+12321231234"))
89                    && body.contains(&format!("From={}", "+12321231234"))
90            } else {
91                false
92            }
93        }
94    }
95
96    struct SendCallBodyMatcher;
97
98    impl wiremock::Match for SendCallBodyMatcher {
99        fn matches(&self, request: &Request) -> bool {
100            let result = String::from_utf8(request.body.clone());
101            if let Ok(body) = result {
102                let message = urlencoding::encode("Hello, World!");
103                let twiml = format!("<Response><Say>{}</Say></Response>", message);
104                body.contains(&format!("Twiml={}", twiml))
105                    && body.contains(&format!("To={}", "+12321231234"))
106                    && body.contains(&format!("From={}", "+12321231234"))
107            } else {
108                false
109            }
110        }
111    }
112
113    fn twilio_client(base_url: String) -> TwilioClient {
114        TwilioClient::new(
115            base_url + "/",
116            Duration::from_secs(1),
117            "account_sid".to_string(),
118            "auth_token".to_string(),
119            "+12321231234".to_string(),
120        )
121    }
122
123    #[tokio::test]
124    async fn send_sms_sends_the_correct_http_request() {
125        let mock_server = MockServer::start().await;
126        let message = "Hello, World!";
127        let to = "+12321231234";
128        let client = twilio_client(mock_server.uri());
129        let url = format!("Accounts/{}/Messages", client.account_sid,);
130
131        Mock::given(header("Content-Type", "application/x-www-form-urlencoded"))
132            .and(path(url))
133            .and(method("POST"))
134            .and(SendSmsBodyMatcher)
135            .respond_with(ResponseTemplate::new(200))
136            .expect(1)
137            .mount(&mock_server)
138            .await;
139
140        let _ = client
141            .send_sms(to.to_string(), message.to_string())
142            .await
143            .unwrap();
144    }
145
146    #[tokio::test]
147    async fn send_call_sends_the_correct_http_request() {
148        let mock_server = MockServer::start().await;
149        let message = "Hello, World!";
150        let to = "+12321231234";
151        let client = twilio_client(mock_server.uri());
152        let url = format!("Accounts/{}/Calls.json", client.account_sid,);
153
154        Mock::given(header("Content-Type", "application/x-www-form-urlencoded"))
155            .and(path(url))
156            .and(method("POST"))
157            .and(SendCallBodyMatcher)
158            .respond_with(ResponseTemplate::new(200))
159            .expect(1)
160            .mount(&mock_server)
161            .await;
162
163        let _ = client
164            .send_call(to.to_string(), message.to_string())
165            .await
166            .unwrap();
167    }
168}