dayu/
lib.rs

1// The MIT License (MIT)
2
3// Copyright (c) 2018 Matrix.Zhang <113445886@qq.com>
4
5// Permission is hereby granted, free of charge, to any person obtaining a copy of
6// this software and associated documentation files (the "Software"), to deal in
7// the Software without restriction, including without limitation the rights to
8// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9// the Software, and to permit persons to whom the Software is furnished to do so,
10// subject to the following conditions:
11
12// The above copyright notice and this permission notice shall be included in all
13// copies or substantial portions of the Software.
14
15// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
22//! This library supports Alibaba's Dayu SMS SDK version of '2017-05-25'.
23//!
24//! ## Basic usage
25//!
26//! ```rust
27//!use dayu::Dayu;
28//!use serde_json::json;
29//!
30//!let dayu = Dayu::new()
31//!     .set_access_key("access_key")
32//!     .set_access_secret("access_secret")
33//!     .set_sign_name("阿里云测试短信");
34//!dayu.sms_send(&["138XXXXXXXX"], "SMS_123456", Some(&json!({"customer": "Rust"}))).await.unwrap();
35//! ```
36
37use std::{
38    collections::BTreeMap,
39    convert::AsRef,
40    fmt::{self, Display, Formatter},
41};
42
43use chrono::{NaiveDate, Utc};
44use futures_util::TryFutureExt;
45use openssl::{hash::MessageDigest, pkey::PKey, sign::Signer};
46use reqwest::Client;
47use serde::{Deserialize, Serialize};
48use serde_json::Value;
49use textnonce::TextNonce;
50use thiserror::Error;
51use url::Url;
52
53static MAX_PAGE_SIZE: u8 = 50;
54static REQUEST_FORMAT: &str = "JSON";
55static SIGN_METHOD: &str = "HMAC-SHA1";
56static SIGNATURE_VERSION: &str = "1.0";
57static VERSION: &str = "2017-05-25";
58
59#[derive(Debug, Error)]
60pub enum DayuError {
61    #[error("config of '{0}' absence")]
62    ConfigAbsence(&'static str),
63    #[error("dayu response error: {0}")]
64    Dayu(DayuFailResponse),
65    #[error("openssl error: {0}")]
66    Openssl(#[from] openssl::error::ErrorStack),
67    #[error("page size '{0}' too large, max is 50")]
68    PageTooLarge(u8),
69    #[error("reqwest error: {0}")]
70    Reqwest(#[from] reqwest::Error),
71    #[error("serde_json error: {0}")]
72    SerdeJson(#[from] serde_json::error::Error),
73    #[error("std io error: {0}")]
74    Stdio(#[from] std::io::Error),
75    #[error("textnonce error: {0}")]
76    TextNonce(String),
77    #[error("url parse error: {0}")]
78    UrlParse(#[from] url::ParseError),
79}
80
81#[derive(Debug, Deserialize)]
82#[serde(rename_all = "PascalCase")]
83pub struct DayuSendResponse {
84    pub biz_id: String,
85}
86
87#[derive(Debug, Deserialize)]
88#[serde(rename_all = "PascalCase")]
89pub struct DayuQueryDetail {
90    pub phone_num: String,
91    pub send_date: String,
92    pub send_status: u8,
93    pub receive_date: String,
94    pub template_code: String,
95    pub content: String,
96    pub err_code: String,
97}
98
99#[derive(Debug, Deserialize)]
100pub struct DayuQueryDetails {
101    #[serde(rename = "SmsSendDetailDTO")]
102    pub inner: Vec<DayuQueryDetail>,
103}
104
105#[derive(Debug, Deserialize)]
106#[serde(rename_all = "PascalCase")]
107pub struct DayuQueryResponse {
108    pub total_count: i32,
109    pub total_page: Option<u8>,
110    #[serde(rename = "SmsSendDetailDTOs")]
111    pub details: Option<DayuQueryDetails>,
112}
113
114#[derive(Debug, Deserialize, Serialize)]
115#[serde(rename_all = "PascalCase")]
116pub struct DayuFailResponse {
117    pub code: String,
118    pub message: String,
119    pub request_id: String,
120}
121
122impl Display for DayuFailResponse {
123    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
124        write!(f, "{}", serde_json::to_string_pretty(self).unwrap())
125    }
126}
127
128#[derive(Debug, Deserialize)]
129#[serde(untagged)]
130pub enum DayuResponse {
131    Send(DayuSendResponse),
132    Query(DayuQueryResponse),
133    Fail(DayuFailResponse),
134}
135
136#[derive(Default, Clone)]
137pub struct Dayu {
138    client: Client,
139    access_key: String,
140    access_secret: String,
141    sign_name: String,
142}
143
144fn make_url(dayu: &Dayu, action: &str, params: &[(&str, &str)]) -> Result<Url, DayuError> {
145    if dayu.access_key.is_empty() {
146        return Err(DayuError::ConfigAbsence("access_key"));
147    }
148
149    if dayu.access_secret.is_empty() {
150        return Err(DayuError::ConfigAbsence("access_secret"));
151    }
152
153    if dayu.sign_name.is_empty() {
154        return Err(DayuError::ConfigAbsence("sign_name"));
155    }
156
157    let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
158
159    TextNonce::sized(32)
160        .map_err(DayuError::TextNonce)
161        .map(|v| v.to_string())
162        .and_then(|text_nonce| {
163            let mut map = BTreeMap::new();
164            map.insert("Format", REQUEST_FORMAT);
165            map.insert("AccessKeyId", &dayu.access_key);
166            map.insert("SignatureMethod", SIGN_METHOD);
167            map.insert("SignatureNonce", &text_nonce);
168            map.insert("SignatureVersion", SIGNATURE_VERSION);
169            map.insert("Timestamp", &timestamp);
170            map.insert("Action", action);
171            map.insert("SignName", &dayu.sign_name);
172            map.insert("Version", VERSION);
173
174            for &(name, value) in params {
175                if !value.is_empty() {
176                    map.insert(name, value);
177                }
178            }
179
180            let mut forms = map
181                .into_iter()
182                .map(|(key, value)| (key, urlencoding::encode(value).into_owned()))
183                .collect::<Vec<(&str, String)>>();
184
185            let mut wait_sign = String::from("GET&%2F&");
186            wait_sign.push_str(
187                &forms
188                    .iter()
189                    .fold(vec![], |mut wait_sign, &(key, ref value)| {
190                        wait_sign
191                            .push(urlencoding::encode(&format!("{}={}", key, value)).into_owned());
192                        wait_sign
193                    })
194                    .join(&urlencoding::encode("&")),
195            );
196
197            PKey::hmac(format!("{}&", &dayu.access_secret).as_bytes())
198                .and_then(|pkey| {
199                    Signer::new(MessageDigest::sha1(), &pkey).and_then(|mut signer| {
200                        signer
201                            .update(wait_sign.as_bytes())
202                            .and_then(|_| signer.sign_to_vec())
203                    })
204                })
205                .map_err(Into::into)
206                .map(|ref signature| {
207                    forms.push((
208                        "Signature",
209                        urlencoding::encode(&base64::encode(signature)).into_owned(),
210                    ))
211                })
212                .and_then(|_| {
213                    Url::parse("https://dysmsapi.aliyuncs.com")
214                        .map_err(Into::into)
215                        .map(|mut url| {
216                            url.set_query(Some(
217                                &forms
218                                    .into_iter()
219                                    .map(|(key, value)| format!("{}={}", key, value))
220                                    .collect::<Vec<String>>()
221                                    .join("&"),
222                            ));
223                            url
224                        })
225                })
226        })
227}
228
229macro_rules! do_request {
230    ($dayu:expr, $action:expr, $params:expr, $type:tt) => {{
231        let url = make_url($dayu, $action, $params)?;
232        $dayu
233            .client
234            .get(url)
235            .send()
236            .and_then(|response| response.json::<DayuResponse>())
237            .await
238            .map_err(Into::into)
239            .and_then(|json_response| match json_response {
240                DayuResponse::$type(v) => Ok(v),
241                DayuResponse::Fail(fail) => Err(DayuError::Dayu(fail)),
242                _ => unreachable!(),
243            })
244    }};
245}
246
247impl Dayu {
248    /// construct new dayu sdk instance
249    pub fn new() -> Self {
250        Self::default()
251    }
252
253    /// set dayu sdk's access key
254    pub fn set_access_key(mut self, access_key: impl Into<String>) -> Self {
255        self.access_key = access_key.into();
256        self
257    }
258
259    /// set dayu sdk's access secret
260    pub fn set_access_secret(mut self, access_secret: impl Into<String>) -> Self {
261        self.access_secret = access_secret.into();
262        self
263    }
264
265    /// set dayu sdk's sign name
266    pub fn set_sign_name(mut self, sign_name: impl Into<String>) -> Self {
267        self.sign_name = sign_name.into();
268        self
269    }
270
271    /// start send sms
272    /// phones: support multi phone number
273    /// template_code: SMS TEMPLATE CODE
274    /// template_param: SMS TEMPLATE PARAMS as JSON
275    pub async fn sms_send<P: AsRef<str>, T: AsRef<str>>(
276        &self,
277        phones: &[P],
278        template_code: T,
279        template_param: Option<&Value>,
280    ) -> Result<DayuSendResponse, DayuError> {
281        let phone_numbers = phones
282            .iter()
283            .map(AsRef::as_ref)
284            .collect::<Vec<&str>>()
285            .join(",");
286
287        let template_param = template_param
288            .map(|v| serde_json::to_string(v).unwrap())
289            .unwrap_or_else(String::new);
290
291        do_request!(
292            self,
293            "SendSms",
294            &[
295                ("TemplateCode", template_code.as_ref()),
296                ("PhoneNumbers", &phone_numbers),
297                ("TemplateParam", &template_param),
298            ],
299            Send
300        )
301    }
302
303    /// query sms send detail
304    pub async fn sms_query(
305        &self,
306        phone_number: &str,
307        biz_id: Option<&str>,
308        send_date: NaiveDate,
309        current_page: u8,
310        page_size: u8,
311    ) -> Result<DayuQueryResponse, DayuError> {
312        if page_size > MAX_PAGE_SIZE {
313            return Err(DayuError::PageTooLarge(page_size));
314        }
315
316        let send_date = send_date.format("%Y%m%d").to_string();
317        let page_size = page_size.to_string();
318        let current_page = current_page.to_string();
319
320        do_request!(
321            self,
322            "QuerySendDetails",
323            &[
324                ("PhoneNumber", phone_number),
325                ("BizId", biz_id.unwrap_or("")),
326                ("SendDate", &send_date),
327                ("PageSize", &page_size),
328                ("CurrentPage", &current_page),
329            ],
330            Query
331        )
332    }
333}