1use std::sync::Arc;
37
38use serde::{Deserialize, Serialize};
39
40use crate::error::{HttpError, WechatError};
41
42use super::{WechatApi, WechatContext};
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46pub enum MediaType {
47 Image,
49 Voice,
51 Video,
53 Thumb,
55}
56
57impl MediaType {
58 pub fn as_str(&self) -> &'static str {
60 match self {
61 MediaType::Image => "image",
62 MediaType::Voice => "voice",
63 MediaType::Video => "video",
64 MediaType::Thumb => "thumb",
65 }
66 }
67}
68
69#[non_exhaustive]
71#[derive(Debug, Clone, Deserialize)]
72pub struct MediaUploadResponse {
73 #[serde(rename = "type")]
75 pub media_type: String,
76 pub media_id: String,
78 pub created_at: i64,
80 #[serde(default)]
81 pub(crate) errcode: i32,
82 #[serde(default)]
83 pub(crate) errmsg: String,
84}
85
86impl MediaUploadResponse {
87 pub fn errcode(&self) -> i32 {
88 self.errcode
89 }
90
91 pub fn errmsg(&self) -> &str {
92 &self.errmsg
93 }
94}
95
96pub struct MediaApi {
100 context: Arc<WechatContext>,
101}
102
103impl MediaApi {
104 pub fn new(context: Arc<WechatContext>) -> Self {
106 Self { context }
107 }
108
109 pub async fn upload_temp_media(
139 &self,
140 media_type: MediaType,
141 filename: &str,
142 data: &[u8],
143 ) -> Result<MediaUploadResponse, WechatError> {
144 let access_token = self.context.token_manager.get_token().await?;
145 let url = format!(
146 "{}{}",
147 self.context.client.base_url(),
148 "/cgi-bin/media/upload"
149 );
150 let query = [
151 ("access_token", access_token.as_str()),
152 ("type", media_type.as_str()),
153 ];
154
155 let part = reqwest::multipart::Part::bytes(data.to_vec()).file_name(filename.to_string());
156 let form = reqwest::multipart::Form::new().part("media", part);
157
158 let request = self
159 .context
160 .client
161 .http()
162 .post(&url)
163 .query(&query)
164 .multipart(form)
165 .build()?;
166 let response = self.context.client.send_request(request).await?;
167 if let Err(error) = response.error_for_status_ref() {
168 return Err(error.into());
169 }
170
171 let value: serde_json::Value = response.json().await?;
172 if let Some((code, message)) = parse_api_error_from_json_value(&value) {
173 return Err(WechatError::Api { code, message });
174 }
175
176 let result: MediaUploadResponse = serde_json::from_value(value)
177 .map_err(|error| WechatError::Http(HttpError::Decode(error.to_string())))?;
178
179 WechatError::check_api(result.errcode(), result.errmsg())?;
180
181 Ok(result)
182 }
183
184 pub async fn get_temp_media(&self, media_id: &str) -> Result<Vec<u8>, WechatError> {
206 let access_token = self.context.token_manager.get_token().await?;
207 let url = format!("{}{}", self.context.client.base_url(), "/cgi-bin/media/get");
208 let query = [
209 ("access_token", access_token.as_str()),
210 ("media_id", media_id),
211 ];
212
213 let request = self.context.client.http().get(&url).query(&query).build()?;
214 let response = self.context.client.send_request(request).await?;
215 if let Err(error) = response.error_for_status_ref() {
216 return Err(error.into());
217 }
218
219 let bytes = response.bytes().await?;
220 if let Some((code, message)) = parse_api_error_from_json_bytes(&bytes) {
221 return Err(WechatError::Api { code, message });
222 }
223
224 Ok(bytes.to_vec())
225 }
226}
227
228fn parse_api_error_from_json_bytes(bytes: &[u8]) -> Option<(i32, String)> {
229 let value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
230 parse_api_error_from_json_value(&value)
231}
232
233fn parse_api_error_from_json_value(value: &serde_json::Value) -> Option<(i32, String)> {
234 let raw_code = value.get("errcode")?.as_i64()?;
235 if raw_code == 0 {
236 return None;
237 }
238
239 let code = i32::try_from(raw_code).unwrap_or_else(|_| {
240 if raw_code.is_negative() {
241 i32::MIN
242 } else {
243 i32::MAX
244 }
245 });
246 let message = value
247 .get("errmsg")
248 .and_then(|v| v.as_str())
249 .unwrap_or("unknown error")
250 .to_string();
251 Some((code, message))
252}
253
254impl WechatApi for MediaApi {
255 fn api_name(&self) -> &'static str {
256 "media"
257 }
258
259 fn context(&self) -> &WechatContext {
260 &self.context
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::types::{AppId, AppSecret};
268 use crate::WechatClient;
269 use wiremock::matchers::{method, path, query_param};
270 use wiremock::{Mock, MockServer, ResponseTemplate};
271
272 fn create_test_context(base_url: &str) -> Arc<WechatContext> {
273 let appid = AppId::new("wx1234567890abcdef").unwrap();
274 let secret = AppSecret::new("secret1234567890ab").unwrap();
275 let client = Arc::new(
276 WechatClient::builder()
277 .appid(appid)
278 .secret(secret)
279 .base_url(base_url)
280 .build()
281 .unwrap(),
282 );
283 let token_manager = Arc::new(crate::token::TokenManager::new((*client).clone()));
284 Arc::new(WechatContext::new(client, token_manager))
285 }
286
287 #[test]
288 fn test_media_type() {
289 assert_eq!(MediaType::Image.as_str(), "image");
290 assert_eq!(MediaType::Voice.as_str(), "voice");
291 assert_eq!(MediaType::Video.as_str(), "video");
292 assert_eq!(MediaType::Thumb.as_str(), "thumb");
293 }
294
295 #[tokio::test]
296 async fn test_upload_temp_media_success() {
297 let mock_server = MockServer::start().await;
298
299 Mock::given(method("GET"))
300 .and(path("/cgi-bin/token"))
301 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
302 "access_token": "test_token",
303 "expires_in": 7200,
304 "errcode": 0,
305 "errmsg": ""
306 })))
307 .mount(&mock_server)
308 .await;
309
310 Mock::given(method("POST"))
311 .and(path("/cgi-bin/media/upload"))
312 .and(query_param("access_token", "test_token"))
313 .and(query_param("type", "image"))
314 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
315 "type": "image",
316 "media_id": "test_media_id_123",
317 "created_at": 1234567890,
318 "errcode": 0,
319 "errmsg": ""
320 })))
321 .mount(&mock_server)
322 .await;
323
324 let context = create_test_context(&mock_server.uri());
325 let media_api = MediaApi::new(context);
326
327 let image_data = b"fake_image_data";
328 let result = media_api
329 .upload_temp_media(MediaType::Image, "test.jpg", image_data)
330 .await;
331
332 assert!(result.is_ok());
333 let response = result.unwrap();
334 assert_eq!(response.media_type, "image");
335 assert_eq!(response.media_id, "test_media_id_123");
336 assert_eq!(response.created_at, 1234567890);
337 }
338
339 #[tokio::test]
340 async fn test_upload_temp_media_api_error() {
341 let mock_server = MockServer::start().await;
342
343 Mock::given(method("GET"))
344 .and(path("/cgi-bin/token"))
345 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
346 "access_token": "test_token",
347 "expires_in": 7200,
348 "errcode": 0,
349 "errmsg": ""
350 })))
351 .mount(&mock_server)
352 .await;
353
354 Mock::given(method("POST"))
355 .and(path("/cgi-bin/media/upload"))
356 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
357 "type": "",
358 "media_id": "",
359 "created_at": 0,
360 "errcode": 40001,
361 "errmsg": "invalid credential"
362 })))
363 .mount(&mock_server)
364 .await;
365
366 let context = create_test_context(&mock_server.uri());
367 let media_api = MediaApi::new(context);
368
369 let image_data = b"fake_image_data";
370 let result = media_api
371 .upload_temp_media(MediaType::Image, "test.jpg", image_data)
372 .await;
373
374 assert!(result.is_err());
375 if let Err(WechatError::Api { code, message }) = result {
376 assert_eq!(code, 40001);
377 assert_eq!(message, "invalid credential");
378 } else {
379 panic!("Expected Api error");
380 }
381 }
382
383 #[tokio::test]
384 async fn test_get_temp_media_success() {
385 let mock_server = MockServer::start().await;
386
387 Mock::given(method("GET"))
388 .and(path("/cgi-bin/token"))
389 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
390 "access_token": "test_token",
391 "expires_in": 7200,
392 "errcode": 0,
393 "errmsg": ""
394 })))
395 .mount(&mock_server)
396 .await;
397
398 Mock::given(method("GET"))
399 .and(path("/cgi-bin/media/get"))
400 .and(query_param("access_token", "test_token"))
401 .and(query_param("media_id", "test_media_id"))
402 .respond_with(
403 ResponseTemplate::new(200).set_body_raw(b"media_binary_data", "image/jpeg"),
404 )
405 .mount(&mock_server)
406 .await;
407
408 let context = create_test_context(&mock_server.uri());
409 let media_api = MediaApi::new(context);
410
411 let result = media_api.get_temp_media("test_media_id").await;
412
413 assert!(result.is_ok());
414 let data = result.unwrap();
415 assert_eq!(data, b"media_binary_data");
416 }
417
418 #[tokio::test]
419 async fn test_get_temp_media_error_json() {
420 let mock_server = MockServer::start().await;
421
422 Mock::given(method("GET"))
423 .and(path("/cgi-bin/token"))
424 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
425 "access_token": "test_token",
426 "expires_in": 7200,
427 "errcode": 0,
428 "errmsg": ""
429 })))
430 .mount(&mock_server)
431 .await;
432
433 Mock::given(method("GET"))
434 .and(path("/cgi-bin/media/get"))
435 .and(query_param("access_token", "test_token"))
436 .and(query_param("media_id", "expired_media"))
437 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
438 "errcode": 40007,
439 "errmsg": "invalid media_id"
440 })))
441 .mount(&mock_server)
442 .await;
443
444 let context = create_test_context(&mock_server.uri());
445 let media_api = MediaApi::new(context);
446
447 let result = media_api.get_temp_media("expired_media").await;
448
449 assert!(result.is_err());
450 match result {
451 Err(WechatError::Api { code, message }) => {
452 assert_eq!(code, 40007);
453 assert_eq!(message, "invalid media_id");
454 }
455 _ => panic!("Expected WechatError::Api"),
456 }
457 }
458}