Skip to main content

douyin_cli/
openapi.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use reqwest::Url;
5use reqwest::blocking::{Client, RequestBuilder};
6use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
7use serde_json::{Map, Value, json};
8
9pub const BASE_URL: &str = "https://open.douyin.com";
10
11pub struct OpenApiClient {
12    base_url: String,
13    client: Client,
14}
15
16impl OpenApiClient {
17    pub fn new() -> Result<Self, String> {
18        Self::with_base_url(BASE_URL)
19    }
20
21    pub fn with_base_url(base_url: &str) -> Result<Self, String> {
22        let client = Client::builder()
23            .connect_timeout(Duration::from_secs(10))
24            .timeout(Duration::from_secs(30))
25            .build()
26            .map_err(|error| error.to_string())?;
27        Ok(Self {
28            base_url: base_url.trim_end_matches('/').to_owned(),
29            client,
30        })
31    }
32
33    pub fn authorize_url(
34        &self,
35        client_key: &str,
36        redirect_uri: &str,
37        scopes: &[String],
38        state: Option<&str>,
39    ) -> Result<String, String> {
40        let mut url =
41            Url::parse(&self.url("/platform/oauth/connect/")).map_err(|error| error.to_string())?;
42        {
43            let mut query = url.query_pairs_mut();
44            query.append_pair("client_key", client_key);
45            query.append_pair("response_type", "code");
46            query.append_pair("scope", &scopes.join(","));
47            query.append_pair("redirect_uri", redirect_uri);
48            if let Some(state) = state {
49                query.append_pair("state", state);
50            }
51        }
52        Ok(url.into())
53    }
54
55    pub fn client_token(&self, client_key: &str, client_secret: &str) -> Result<Value, String> {
56        self.request(RequestSpec {
57            method: "POST",
58            path: "/oauth/client_token/",
59            form: Some(HashMap::from([
60                ("client_key".to_owned(), client_key.to_owned()),
61                ("client_secret".to_owned(), client_secret.to_owned()),
62                ("grant_type".to_owned(), "client_credential".to_owned()),
63            ])),
64            auth_required: false,
65            ..RequestSpec::default()
66        })
67    }
68
69    pub fn access_token(
70        &self,
71        client_key: &str,
72        client_secret: &str,
73        code: &str,
74    ) -> Result<Value, String> {
75        self.request(RequestSpec {
76            method: "POST",
77            path: "/oauth/access_token/",
78            form: Some(HashMap::from([
79                ("client_key".to_owned(), client_key.to_owned()),
80                ("client_secret".to_owned(), client_secret.to_owned()),
81                ("code".to_owned(), code.to_owned()),
82                ("grant_type".to_owned(), "authorization_code".to_owned()),
83            ])),
84            auth_required: false,
85            ..RequestSpec::default()
86        })
87    }
88
89    pub fn refresh_token(&self, client_key: &str, refresh_token: &str) -> Result<Value, String> {
90        self.request(RequestSpec {
91            method: "POST",
92            path: "/oauth/refresh_token/",
93            form: Some(HashMap::from([
94                ("client_key".to_owned(), client_key.to_owned()),
95                ("grant_type".to_owned(), "refresh_token".to_owned()),
96                ("refresh_token".to_owned(), refresh_token.to_owned()),
97            ])),
98            auth_required: false,
99            ..RequestSpec::default()
100        })
101    }
102
103    pub fn renew_refresh_token(
104        &self,
105        client_key: &str,
106        refresh_token: &str,
107    ) -> Result<Value, String> {
108        self.request(RequestSpec {
109            method: "POST",
110            path: "/oauth/renew_refresh_token/",
111            form: Some(HashMap::from([
112                ("client_key".to_owned(), client_key.to_owned()),
113                ("refresh_token".to_owned(), refresh_token.to_owned()),
114            ])),
115            auth_required: false,
116            ..RequestSpec::default()
117        })
118    }
119
120    pub fn request(&self, spec: RequestSpec<'_>) -> Result<Value, String> {
121        if spec.auth_required && spec.token.is_none_or(str::is_empty) {
122            return Err("调用 OpenAPI 需要 access-token 或 client-token".to_owned());
123        }
124        let method = spec
125            .method
126            .parse::<reqwest::Method>()
127            .map_err(|error| format!("HTTP method 无效: {error}"))?;
128        let mut request = self.client.request(method, self.url(spec.path));
129        if let Some(token) = spec.token {
130            request = request.header("access-token", token);
131        }
132        request = add_headers(request, spec.headers)?;
133        if let Some(params) = spec.params {
134            request = request.query(&params);
135        }
136        if let Some(form) = spec.form {
137            request = request.form(&form);
138        } else if let Some(body) = spec.json_body {
139            request = request.json(&body);
140        }
141
142        let response = request.send().map_err(|error| error.to_string())?;
143        let status = response.status();
144        let text = response.text().map_err(|error| error.to_string())?;
145        if !status.is_success() {
146            return Err(format!("OpenAPI HTTP 请求失败: {status} {text}"));
147        }
148        let data: Value =
149            serde_json::from_str(&text).map_err(|_| format!("OpenAPI 响应不是 JSON: {text}"))?;
150        if !data.is_object() {
151            return Err("OpenAPI 响应不是 JSON object".to_owned());
152        }
153        Ok(data)
154    }
155
156    fn url(&self, path: &str) -> String {
157        if path.starts_with("http://") || path.starts_with("https://") {
158            path.to_owned()
159        } else {
160            format!("{}/{}", self.base_url, path.trim_start_matches('/'))
161        }
162    }
163}
164
165#[derive(Default)]
166pub struct RequestSpec<'a> {
167    pub method: &'a str,
168    pub path: &'a str,
169    pub token: Option<&'a str>,
170    pub params: Option<HashMap<String, String>>,
171    pub json_body: Option<Value>,
172    pub form: Option<HashMap<String, String>>,
173    pub headers: Option<HashMap<String, String>>,
174    pub auth_required: bool,
175}
176
177pub fn im_message_body(
178    to_user_id: &str,
179    message_type: &str,
180    content: Value,
181    persona_id: Option<&str>,
182    client_msg_id: Option<&str>,
183) -> Value {
184    let mut body = Map::from_iter([
185        ("to_user_id".to_owned(), json!(to_user_id)),
186        ("message_type".to_owned(), json!(message_type)),
187        ("content".to_owned(), json!(content.to_string())),
188    ]);
189    if let Some(value) = persona_id {
190        body.insert("persona_id".to_owned(), json!(value));
191    }
192    if let Some(value) = client_msg_id {
193        body.insert("client_msg_id".to_owned(), json!(value));
194    }
195    Value::Object(body)
196}
197
198fn add_headers(
199    mut request: RequestBuilder,
200    headers: Option<HashMap<String, String>>,
201) -> Result<RequestBuilder, String> {
202    let Some(headers) = headers else {
203        return Ok(request);
204    };
205    let mut values = HeaderMap::new();
206    for (key, value) in headers {
207        let key = HeaderName::try_from(key).map_err(|error| error.to_string())?;
208        let value = HeaderValue::try_from(value).map_err(|error| error.to_string())?;
209        values.insert(key, value);
210    }
211    request = request.headers(values);
212    Ok(request)
213}
214
215#[cfg(test)]
216mod tests {
217    use super::{OpenApiClient, RequestSpec, im_message_body};
218    use serde_json::json;
219
220    #[test]
221    fn authorize_url_encodes_values() {
222        let client = OpenApiClient::new().unwrap();
223        let url = client
224            .authorize_url(
225                "client",
226                "https://example.com/callback",
227                &["user_info".to_owned(), "item.comment".to_owned()],
228                Some("state value"),
229            )
230            .unwrap();
231        assert!(url.starts_with("https://open.douyin.com/platform/oauth/connect/?"));
232        assert!(url.contains("scope=user_info%2Citem.comment"));
233        assert!(url.contains("redirect_uri=https%3A%2F%2Fexample.com%2Fcallback"));
234        assert!(url.contains("state=state+value"));
235    }
236
237    #[test]
238    fn request_rejects_missing_token_before_network() {
239        let client = OpenApiClient::new().unwrap();
240        let error = client
241            .request(RequestSpec {
242                method: "GET",
243                path: "/oauth/userinfo/",
244                auth_required: true,
245                ..RequestSpec::default()
246            })
247            .unwrap_err();
248        assert!(error.contains("access-token"));
249    }
250
251    #[test]
252    fn im_body_serializes_content_as_compact_json_string() {
253        let body = im_message_body(
254            "user",
255            "text",
256            json!({"text": "你好"}),
257            None,
258            Some("client-msg"),
259        );
260        assert_eq!(body["content"], "{\"text\":\"你好\"}");
261        assert_eq!(body["client_msg_id"], "client-msg");
262    }
263}