aliyun_openapi_core_rust_sdk/client/
roa.rs1use 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 pub code: String,
22 pub message: String,
24 #[serde(default)]
26 pub request_id: String,
27 #[serde(default)]
29 pub recommend: String,
30}
31
32const 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#[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 access_key_id: String,
60 access_key_secret: String,
62 endpoint: String,
64 request: Request,
66}
67
68impl ROAClient {
69 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 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 pub fn get(self, uri: impl Into<String>) -> Self {
97 self.request("GET".to_string(), uri.into())
98 }
99
100 pub fn post(self, uri: impl Into<String>) -> Self {
104 self.request("POST".to_string(), uri.into())
105 }
106
107 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 pub fn version(mut self, version: impl Into<String>) -> Self {
127 self.request.version = version.into();
128
129 self
130 }
131
132 pub fn body(mut self, body: impl Into<String>) -> Result<Self> {
136 let body = body.into();
138 let mut hasher = Md5::new();
139 hasher.update(body.as_bytes());
140 let md5_result = hasher.finalize();
141
142 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 self.request.body = Some(body);
152
153 Ok(self)
154 }
155
156 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 pub fn project(mut self, project: impl Into<String>) -> Self {
170 self.request.project = Some(project.into());
171
172 self
173 }
174
175 pub fn timeout(mut self, timeout: Duration) -> Self {
179 self.request.timeout = Some(timeout);
180
181 self
182 }
183
184 pub async fn json<T: DeserializeOwned>(self) -> Result<T> {
187 Ok(self.send().await?.json::<T>().await?)
188 }
189
190 pub async fn text(self) -> Result<String> {
193 Ok(self.send().await?.text().await?)
194 }
195
196 pub async fn send(mut self) -> Result<Response> {
199 for (k, v) in DEFAULT_HEADER.iter() {
201 self.request.headers.insert(*k, v.parse()?);
202 }
203
204 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 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 let nonce = Uuid::new_v4().to_string();
224 self.request
225 .headers
226 .insert("x-acs-signature-nonce", nonce.parse()?);
227
228 let authorization = format!("acs {}:{}", self.access_key_id, self.signature()?);
231 self.request
232 .headers
233 .insert("Authorization", authorization.parse()?);
234
235 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 if let Some(body) = self.request.body {
251 http_client = http_client.body(body);
252 }
253
254 if !self.request.query.is_empty() {
256 http_client = http_client.query(&self.request.query);
257 }
258
259 let response = http_client.headers(self.request.headers).send().await?;
261
262 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 Ok(response)
274 }
275
276 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 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 fn signature(&self) -> Result<String> {
316 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 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 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 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 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 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 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 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}