aliyun_openapi_core_rust_sdk/client/
roa.rs

1use std::{collections::HashMap, time::Duration};
2
3use hmac::{Hmac, Mac};
4use md5::{Digest, Md5};
5use reqwest::{
6    header::{HeaderMap, HeaderValue},
7    ClientBuilder, Response,
8};
9use serde::{de::DeserializeOwned, Deserialize, Serialize};
10use sha1::Sha1;
11use time::{macros::format_description, OffsetDateTime};
12use url::Url;
13use uuid::Uuid;
14
15use crate::client::error::{Error, Result};
16
17#[derive(Debug, Deserialize, Serialize)]
18#[serde(rename_all = "PascalCase")]
19pub struct ROAServiceError {
20    /// Error code
21    pub code: String,
22    /// Error message
23    pub message: String,
24    /// Request id
25    #[serde(default)]
26    pub request_id: String,
27    /// Recommend
28    #[serde(default)]
29    pub recommend: String,
30}
31
32/// Default const header.
33const AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
34const DEFAULT_HEADER: &[(&str, &str)] = &[
35    ("accept", "application/json"),
36    ("x-acs-signature-method", "HMAC-SHA1"),
37    ("user-agent", AGENT),
38    ("x-sdk-client", AGENT),
39];
40
41type HamcSha1 = Hmac<Sha1>;
42
43/// Config for request.
44#[derive(Clone, Debug, Default)]
45struct Request {
46    method: String,
47    uri: String,
48    body: Option<String>,
49    query: Vec<(String, String)>,
50    headers: HeaderMap,
51    project: Option<String>,
52    version: String,
53    timeout: Option<Duration>,
54}
55
56#[derive(Clone, Debug)]
57pub struct ROAClient {
58    /// The access key id of aliyun developer account.
59    access_key_id: String,
60    /// The access key secret of aliyun developer account.
61    access_key_secret: String,
62    /// The api endpoint of aliyun api service (need start with http:// or https://).
63    endpoint: String,
64    /// The config of http request.
65    request: Request,
66}
67
68impl ROAClient {
69    /// Create a api client.
70    pub fn new(
71        access_key_id: impl Into<String>,
72        access_key_secret: impl Into<String>,
73        endpoint: impl Into<String>,
74    ) -> Self {
75        ROAClient {
76            access_key_id: access_key_id.into(),
77            access_key_secret: access_key_secret.into(),
78            endpoint: endpoint.into(),
79            request: Default::default(),
80        }
81    }
82
83    /// Create a request with the `method` and `uri`.
84    ///
85    /// Returns a `Self` for send request.
86    pub fn request(mut self, method: impl Into<String>, uri: impl Into<String>) -> Self {
87        self.request.method = method.into();
88        self.request.uri = uri.into();
89
90        self
91    }
92
93    /// Create a `GET` request with the `uri`.
94    ///
95    /// Returns a `Self` for send request.
96    pub fn get(self, uri: impl Into<String>) -> Self {
97        self.request("GET".to_string(), uri.into())
98    }
99
100    /// Create a `POST` request with the `uri`.
101    ///
102    /// Returns a `Self` for send request.
103    pub fn post(self, uri: impl Into<String>) -> Self {
104        self.request("POST".to_string(), uri.into())
105    }
106
107    /// Set queries for request.
108    ///
109    /// Returns a `Self` for send request.
110    pub fn query<I, T>(mut self, queries: I) -> Self
111    where
112        I: IntoIterator<Item = (T, T)>,
113        T: Into<String>,
114    {
115        self.request.query = queries
116            .into_iter()
117            .map(|v| (v.0.into(), v.1.into()))
118            .collect();
119
120        self
121    }
122
123    /// Set version for request.
124    ///
125    /// Returns a `Self` for send request.
126    pub fn version(mut self, version: impl Into<String>) -> Self {
127        self.request.version = version.into();
128
129        self
130    }
131
132    /// Set body for request.
133    ///
134    /// Returns a `Self` for send request.
135    pub fn body(mut self, body: impl Into<String>) -> Result<Self> {
136        // compute body length and md5.
137        let body = body.into();
138        let mut hasher = Md5::new();
139        hasher.update(body.as_bytes());
140        let md5_result = hasher.finalize();
141
142        // update headers.
143        self.request
144            .headers
145            .insert("content-length", body.len().to_string().parse()?);
146        self.request
147            .headers
148            .insert("content-md5", base64::encode(md5_result).parse()?);
149
150        // store body string.
151        self.request.body = Some(body);
152
153        Ok(self)
154    }
155
156    /// Set header for request.
157    ///
158    /// Returns a `Self` for send request.
159    pub fn header(mut self, headers: impl Into<HashMap<String, String>>) -> Result<Self> {
160        self.request.headers = (&headers.into())
161            .try_into()
162            .map_err(|e| Error::InvalidRequest(format!("Cannot parse header: {}", e)))?;
163        Ok(self)
164    }
165
166    /// Set project for request.
167    ///
168    /// Returns a `Self` for send request.
169    pub fn project(mut self, project: impl Into<String>) -> Self {
170        self.request.project = Some(project.into());
171
172        self
173    }
174
175    /// Set a timeout for connect, read and write operations of a `Client`.
176    ///
177    /// Default is no timeout.
178    pub fn timeout(mut self, timeout: Duration) -> Self {
179        self.request.timeout = Some(timeout);
180
181        self
182    }
183
184    /// Send a request to service.
185    /// Try to deserialize the response body as JSON.
186    pub async fn json<T: DeserializeOwned>(self) -> Result<T> {
187        Ok(self.send().await?.json::<T>().await?)
188    }
189
190    /// Send a request to service.
191    /// Try to deserialize the response body as TEXT.
192    pub async fn text(self) -> Result<String> {
193        Ok(self.send().await?.text().await?)
194    }
195
196    /// Send a request to service.
197    /// Return client Response.
198    pub async fn send(mut self) -> Result<Response> {
199        // add const header
200        for (k, v) in DEFAULT_HEADER.iter() {
201            self.request.headers.insert(*k, v.parse()?);
202        }
203
204        // add host header.
205        let endpoint = Url::parse(&self.endpoint)
206            .map_err(|e| Error::InvalidRequest(format!("Invalid endpoint: {e}")))?;
207        let host = endpoint
208            .host_str()
209            .ok_or_else(|| Error::InvalidRequest(format!("Invalid endpoint: {endpoint}")))?;
210        self.request.headers.insert("host", host.parse()?);
211
212        // add date header.
213        // RFC 1123: %a, %d %b %Y %H:%M:%S GMT
214        let format = format_description!(
215            "[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] GMT"
216        );
217        let ts = OffsetDateTime::now_utc()
218            .format(&format)
219            .map_err(|e| Error::InvalidRequest(format!("Invalid RFC 1123 Date: {}", e)))?;
220        self.request.headers.insert("date", ts.parse()?);
221
222        // add nonce header.
223        let nonce = Uuid::new_v4().to_string();
224        self.request
225            .headers
226            .insert("x-acs-signature-nonce", nonce.parse()?);
227
228        // compute `Authorization` field.
229        // Authorization = "acs <AccessKeyId>:<Signature>"
230        let authorization = format!("acs {}:{}", self.access_key_id, self.signature()?);
231        self.request
232            .headers
233            .insert("Authorization", authorization.parse()?);
234
235        // build http client.
236        let final_url = format!("{}{}", self.endpoint, self.request.uri);
237        let mut http_client_builder = ClientBuilder::new();
238        if let Some(timeout) = self.request.timeout {
239            http_client_builder = http_client_builder.timeout(timeout);
240        }
241        let mut http_client = http_client_builder.build()?.request(
242            self.request
243                .method
244                .parse()
245                .map_err(|e| Error::InvalidRequest(format!("Invalid HTTP method: {}", e)))?,
246            &final_url,
247        );
248
249        // set body.
250        if let Some(body) = self.request.body {
251            http_client = http_client.body(body);
252        }
253
254        // set query.
255        if !self.request.query.is_empty() {
256            http_client = http_client.query(&self.request.query);
257        }
258
259        // send request.
260        let response = http_client.headers(self.request.headers).send().await?;
261
262        // check HTTP StatusCode.
263        if !response.status().is_success() {
264            let result = response.json::<ROAServiceError>().await?;
265            return Err(Error::InvalidResponse {
266                request_id: result.request_id,
267                error_code: result.code,
268                error_message: result.message,
269            });
270        }
271
272        // return response.
273        Ok(response)
274    }
275
276    /// Compute canonicalized headers.
277    fn canonicalized_headers(&self) -> String {
278        let mut headers: Vec<(String, String)> = self
279            .request
280            .headers
281            .iter()
282            .filter_map(|(k, v)| {
283                let k = k.as_str().to_lowercase();
284                if k.starts_with("x-acs-") {
285                    Some((k, v.to_str().unwrap().to_string()))
286                } else {
287                    None
288                }
289            })
290            .collect();
291        headers.sort_by(|a, b| a.0.cmp(&b.0));
292
293        let headers: Vec<String> = headers
294            .iter()
295            .map(|(k, v)| format!("{}:{}", k, v))
296            .collect();
297
298        headers.join("\n")
299    }
300
301    /// Compute canonicalized resource.
302    fn canonicalized_resource(&self) -> String {
303        if !self.request.query.is_empty() {
304            let mut params = self.request.query.clone();
305            params.sort_by_key(|item| item.0.clone());
306            let params: Vec<String> = params.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
307            let sorted_query_string = params.join("&");
308            format!("{}?{}", self.request.uri, sorted_query_string)
309        } else {
310            self.request.uri.clone()
311        }
312    }
313
314    /// Compute signature for request.
315    fn signature(&self) -> Result<String> {
316        // build body.
317        let canonicalized_headers = self.canonicalized_headers();
318        let canonicalized_resource = self.canonicalized_resource();
319        let body = format!(
320            "{}\n{}\n{}\n{}\n{}\n{}\n{}",
321            self.request.method.to_uppercase(),
322            self.request.headers["accept"].to_str().unwrap(),
323            self.request
324                .headers
325                .get("content-md5")
326                .unwrap_or(&HeaderValue::from_static(""))
327                .to_str()
328                .unwrap(),
329            self.request
330                .headers
331                .get("content-type")
332                .unwrap_or(&HeaderValue::from_static(""))
333                .to_str()
334                .unwrap(),
335            self.request.headers["date"].to_str().unwrap(),
336            canonicalized_headers,
337            canonicalized_resource
338        );
339
340        // sign body.
341        let mut mac = HamcSha1::new_from_slice(self.access_key_secret.as_bytes())
342            .map_err(|e| Error::InvalidRequest(format!("Invalid HMAC-SHA1 secret key: {}", e)))?;
343        mac.update(body.as_bytes());
344        let result = mac.finalize();
345        let code = result.into_bytes();
346
347        Ok(base64::encode(code))
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use std::env;
354
355    use serde_json::json;
356
357    use super::*;
358
359    #[tokio::test]
360    async fn roa_client_invalid_access_key_id_test() -> Result<()> {
361        // create roa style api client.
362        let aliyun_openapi_client = ROAClient::new(
363            env::var("ACCESS_KEY_ID").unwrap(),
364            env::var("ACCESS_KEY_SECRET").unwrap(),
365            "https://ros.aliyuncs.com",
366        );
367
368        // call `DescribeRegions` with empty queries.
369        let response = aliyun_openapi_client
370            .version("2015-09-01")
371            .get("/regions")
372            .text()
373            .await?;
374
375        assert!(response.contains("Regions"));
376
377        Ok(())
378    }
379
380    #[tokio::test]
381    async fn roa_client_get_with_timeout() -> Result<()> {
382        // create roa style api client.
383        let aliyun_openapi_client = ROAClient::new(
384            env::var("ACCESS_KEY_ID").unwrap(),
385            env::var("ACCESS_KEY_SECRET").unwrap(),
386            "https://ros.aliyuncs.com",
387        );
388
389        // call `DescribeRegions` with empty queries.
390        let response = aliyun_openapi_client
391            .version("2015-09-01")
392            .get("/regions")
393            .timeout(Duration::from_millis(1))
394            .text()
395            .await;
396
397        assert!(response.is_err());
398
399        Ok(())
400    }
401
402    #[tokio::test]
403    async fn roa_client_get_with_query_test() -> Result<()> {
404        // create roa style api client.
405        let aliyun_openapi_client = ROAClient::new(
406            env::var("ACCESS_KEY_ID").unwrap(),
407            env::var("ACCESS_KEY_SECRET").unwrap(),
408            "http://mt.aliyuncs.com",
409        );
410
411        // create params.
412        let mut params = HashMap::new();
413        params.insert("SourceText", "你好");
414        params.insert("SourceLanguage", "zh");
415        params.insert("TargetLanguage", "en");
416        params.insert("FormatType", "text");
417        params.insert("Scene", "general");
418
419        let response = aliyun_openapi_client
420            .version("2018-04-08")
421            .post("/api/translate/web/general")
422            .header([("Content-Type".to_string(), "application/json".to_string())])?
423            .body(json!(params).to_string())?
424            .text()
425            .await?;
426
427        assert!(response.contains("Hello"));
428
429        Ok(())
430    }
431}