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(¶ms);
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}