Skip to main content

wechat_mp_sdk/api/
media.rs

1//! Temporary Media Management API
2//!
3//! Provides APIs for uploading and downloading temporary media files.
4//!
5//! ## Overview
6//!
7//! WeChat Mini Programs support temporary media files that can be used in
8//! customer service messages. These files are valid for 3 days after upload.
9//!
10//! ## Supported Media Types
11//!
12//! - Image (image): jpg, png formats
13//! - Voice (voice): mp3, wav, amr formats
14//! - Video (video): mp4 format
15//! - Thumbnail (thumb): jpg, png formats
16//!
17//! ## Example
18//!
19//! ```ignore
20//! use wechat_mp_sdk::api::media::{MediaApi, MediaType};
21//! use wechat_mp_sdk::api::WechatContext;
22//! use std::sync::Arc;
23//!
24//! let context = Arc::new(WechatContext::new(client, token_manager));
25//! let media_api = MediaApi::new(context);
26//!
27//! // Upload an image
28//! let image_data = std::fs::read("image.jpg")?;
29//! let response = media_api.upload_temp_media(MediaType::Image, "image.jpg", &image_data).await?;
30//! println!("Media ID: {}", response.media_id);
31//!
32//! // Download media
33//! let data = media_api.get_temp_media(&response.media_id).await?;
34//! ```
35
36use std::sync::Arc;
37
38use serde::{Deserialize, Serialize};
39
40use crate::error::{HttpError, WechatError};
41
42use super::{WechatApi, WechatContext};
43
44/// Media type for temporary media upload
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46pub enum MediaType {
47    /// Image file (jpg, png)
48    Image,
49    /// Voice file (mp3, wav, amr)
50    Voice,
51    /// Video file (mp4)
52    Video,
53    /// Thumbnail file (jpg, png)
54    Thumb,
55}
56
57impl MediaType {
58    /// Get the string representation of the media type
59    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/// Response from temporary media upload
70#[non_exhaustive]
71#[derive(Debug, Clone, Deserialize)]
72pub struct MediaUploadResponse {
73    /// Type of the uploaded media
74    #[serde(rename = "type")]
75    pub media_type: String,
76    /// Unique identifier for the uploaded media
77    pub media_id: String,
78    /// Unix timestamp when the media was created
79    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
96/// Temporary Media API
97///
98/// Provides methods for uploading and downloading temporary media files.
99pub struct MediaApi {
100    context: Arc<WechatContext>,
101}
102
103impl MediaApi {
104    /// Create a new MediaApi instance
105    pub fn new(context: Arc<WechatContext>) -> Self {
106        Self { context }
107    }
108
109    /// Upload temporary media
110    ///
111    /// POST /cgi-bin/media/upload?access_token=ACCESS_TOKEN&type=TYPE
112    ///
113    /// Uploads a media file to WeChat servers. The media will be available
114    /// for 3 days (72 hours) after upload.
115    ///
116    /// # Arguments
117    /// * `media_type` - Type of media (Image, Voice, Video, Thumb)
118    /// * `filename` - Name of the file (used for reference)
119    /// * `data` - Raw file content as bytes
120    ///
121    /// # Returns
122    /// `MediaUploadResponse` containing the media_id and metadata
123    ///
124    /// # Errors
125    /// Returns `WechatError` if the upload fails or API returns an error
126    ///
127    /// # Example
128    ///
129    /// ```ignore
130    /// let image_data = std::fs::read("image.jpg")?;
131    /// let response = media_api.upload_temp_media(
132    ///     MediaType::Image,
133    ///     "image.jpg",
134    ///     &image_data
135    /// ).await?;
136    /// println!("Media ID: {}", response.media_id);
137    /// ```
138    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    /// Get temporary media
185    ///
186    /// GET /cgi-bin/media/get?access_token=ACCESS_TOKEN&media_id=MEDIA_ID
187    ///
188    /// Downloads a previously uploaded temporary media file.
189    ///
190    /// # Arguments
191    /// * `media_id` - Media ID returned from upload_temp_media
192    ///
193    /// # Returns
194    /// Raw bytes of the media file
195    ///
196    /// # Errors
197    /// Returns `WechatError` if the download fails or media is not found
198    ///
199    /// # Example
200    ///
201    /// ```ignore
202    /// let data = media_api.get_temp_media("media_id_123").await?;
203    /// std::fs::write("downloaded.jpg", &data)?;
204    /// ```
205    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}