1use std::fmt;
17use std::sync::Arc;
18
19use serde::{Deserialize, Serialize};
20
21use super::{WechatApi, WechatContext};
22use crate::error::WechatError;
23
24#[derive(Debug, Clone, Serialize)]
29struct ClearQuotaRequest {
30 appid: String,
31}
32
33#[derive(Debug, Clone, Serialize)]
34struct GetApiQuotaRequest {
35 cgi_path: String,
36}
37
38#[derive(Debug, Clone, Serialize)]
39struct ClearApiQuotaRequest {
40 cgi_path: String,
41}
42
43#[derive(Clone, Serialize)]
44struct ClearQuotaByAppSecretRequest {
45 appid: String,
46 appsecret: String,
47}
48
49impl fmt::Debug for ClearQuotaByAppSecretRequest {
50 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51 f.debug_struct("ClearQuotaByAppSecretRequest")
52 .field("appid", &self.appid)
53 .field("appsecret", &"[REDACTED]")
54 .finish()
55 }
56}
57
58#[derive(Debug, Clone, Serialize)]
59struct GetRidInfoRequest {
60 rid: String,
61}
62
63#[derive(Debug, Clone, Serialize)]
64struct CallbackCheckRequest {
65 action: String,
66 check_operator: String,
67}
68
69#[derive(Debug, Clone, Deserialize)]
75struct BaseApiResponse {
76 #[serde(default)]
77 errcode: i32,
78 #[serde(default)]
79 errmsg: String,
80}
81
82#[non_exhaustive]
88#[derive(Debug, Clone, Default, Deserialize, Serialize)]
89pub struct QuotaInfo {
90 #[serde(default)]
92 pub daily_limit: i64,
93 #[serde(default)]
95 pub used: i64,
96 #[serde(default)]
98 pub remain: i64,
99}
100
101#[non_exhaustive]
103#[derive(Debug, Clone, Deserialize, Serialize)]
104pub struct ApiQuotaResponse {
105 #[serde(default)]
107 pub quota: QuotaInfo,
108 #[serde(default)]
110 pub(crate) errcode: i32,
111 #[serde(default)]
113 pub(crate) errmsg: String,
114}
115
116#[non_exhaustive]
118#[derive(Debug, Clone, Default, Deserialize, Serialize)]
119pub struct RidRequestInfo {
120 #[serde(default)]
122 pub invoke_time: i64,
123 #[serde(default)]
125 pub cost_in_ms: i64,
126 #[serde(default)]
128 pub request_url: String,
129 #[serde(default)]
131 pub request_body: String,
132 #[serde(default)]
134 pub response_body: String,
135 #[serde(default)]
137 pub client_ip: String,
138}
139
140#[non_exhaustive]
142#[derive(Debug, Clone, Deserialize, Serialize)]
143pub struct RidInfoResponse {
144 #[serde(default)]
146 pub request: RidRequestInfo,
147 #[serde(default)]
149 pub(crate) errcode: i32,
150 #[serde(default)]
152 pub(crate) errmsg: String,
153}
154
155#[non_exhaustive]
157#[derive(Debug, Clone, Default, Deserialize, Serialize)]
158pub struct DnsInfo {
159 #[serde(default)]
161 pub ip: String,
162 #[serde(default)]
164 pub real_operator: String,
165}
166
167#[non_exhaustive]
169#[derive(Debug, Clone, Default, Deserialize, Serialize)]
170pub struct PingInfo {
171 #[serde(default)]
173 pub ip: String,
174 #[serde(default)]
176 pub from_operator: String,
177 #[serde(default)]
179 pub package_loss: String,
180 #[serde(default)]
182 pub time: String,
183}
184
185#[non_exhaustive]
187#[derive(Debug, Clone, Deserialize, Serialize)]
188pub struct CallbackCheckResponse {
189 #[serde(default)]
191 pub dns: Vec<DnsInfo>,
192 #[serde(default)]
194 pub ping: Vec<PingInfo>,
195 #[serde(default)]
197 pub(crate) errcode: i32,
198 #[serde(default)]
200 pub(crate) errmsg: String,
201}
202
203#[non_exhaustive]
205#[derive(Debug, Clone, Deserialize, Serialize)]
206pub struct IpListResponse {
207 #[serde(default)]
209 pub ip_list: Vec<String>,
210 #[serde(default)]
212 pub(crate) errcode: i32,
213 #[serde(default)]
215 pub(crate) errmsg: String,
216}
217
218pub struct OpenApiApi {
226 context: Arc<WechatContext>,
227}
228
229impl OpenApiApi {
230 pub fn new(context: Arc<WechatContext>) -> Self {
232 Self { context }
233 }
234
235 pub async fn clear_quota(&self) -> Result<(), WechatError> {
242 let body = ClearQuotaRequest {
243 appid: self.context.client.appid().to_string(),
244 };
245 let response: BaseApiResponse = self
246 .context
247 .authed_post("/cgi-bin/clear_quota", &body)
248 .await?;
249 WechatError::check_api(response.errcode, &response.errmsg)?;
250 Ok(())
251 }
252
253 pub async fn get_api_quota(&self, cgi_path: &str) -> Result<ApiQuotaResponse, WechatError> {
260 let body = GetApiQuotaRequest {
261 cgi_path: cgi_path.to_string(),
262 };
263 let response: ApiQuotaResponse = self
264 .context
265 .authed_post("/cgi-bin/openapi/quota/get", &body)
266 .await?;
267 WechatError::check_api(response.errcode, &response.errmsg)?;
268 Ok(response)
269 }
270
271 pub async fn clear_api_quota(&self, cgi_path: &str) -> Result<(), WechatError> {
278 let body = ClearApiQuotaRequest {
279 cgi_path: cgi_path.to_string(),
280 };
281 let response: BaseApiResponse = self
282 .context
283 .authed_post("/cgi-bin/openapi/quota/clear", &body)
284 .await?;
285 WechatError::check_api(response.errcode, &response.errmsg)?;
286 Ok(())
287 }
288
289 pub async fn clear_quota_by_app_secret(&self) -> Result<(), WechatError> {
296 let path = "/cgi-bin/clear_quota/v2";
297 let body = ClearQuotaByAppSecretRequest {
298 appid: self.context.client.appid().to_string(),
299 appsecret: self.context.client.secret().to_string(),
300 };
301 let response: BaseApiResponse = self.context.client.post(path, &body).await?;
302 WechatError::check_api(response.errcode, &response.errmsg)?;
303 Ok(())
304 }
305
306 pub async fn get_rid_info(&self, rid: &str) -> Result<RidInfoResponse, WechatError> {
313 let body = GetRidInfoRequest {
314 rid: rid.to_string(),
315 };
316 let response: RidInfoResponse = self
317 .context
318 .authed_post("/cgi-bin/openapi/rid/get", &body)
319 .await?;
320 WechatError::check_api(response.errcode, &response.errmsg)?;
321 Ok(response)
322 }
323
324 pub async fn callback_check(
332 &self,
333 action: &str,
334 check_operator: &str,
335 ) -> Result<CallbackCheckResponse, WechatError> {
336 let body = CallbackCheckRequest {
337 action: action.to_string(),
338 check_operator: check_operator.to_string(),
339 };
340 let response: CallbackCheckResponse = self
341 .context
342 .authed_post("/cgi-bin/callback/check", &body)
343 .await?;
344 WechatError::check_api(response.errcode, &response.errmsg)?;
345 Ok(response)
346 }
347
348 pub async fn get_api_domain_ip(&self) -> Result<IpListResponse, WechatError> {
352 let response: IpListResponse = self
353 .context
354 .authed_get("/cgi-bin/get_api_domain_ip", &[])
355 .await?;
356 WechatError::check_api(response.errcode, &response.errmsg)?;
357 Ok(response)
358 }
359
360 pub async fn get_callback_ip(&self) -> Result<IpListResponse, WechatError> {
364 let response: IpListResponse = self
365 .context
366 .authed_get("/cgi-bin/getcallbackip", &[])
367 .await?;
368 WechatError::check_api(response.errcode, &response.errmsg)?;
369 Ok(response)
370 }
371}
372
373impl WechatApi for OpenApiApi {
374 fn context(&self) -> &WechatContext {
375 &self.context
376 }
377
378 fn api_name(&self) -> &'static str {
379 "openapi"
380 }
381}
382
383#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::client::WechatClient;
391 use crate::token::TokenManager;
392 use crate::types::{AppId, AppSecret};
393
394 fn create_test_context(base_url: &str) -> Arc<WechatContext> {
395 let appid = AppId::new("wx1234567890abcdef").unwrap();
396 let secret = AppSecret::new("secret1234567890ab").unwrap();
397 let client = Arc::new(
398 WechatClient::builder()
399 .appid(appid)
400 .secret(secret)
401 .base_url(base_url)
402 .build()
403 .unwrap(),
404 );
405 let token_manager = Arc::new(TokenManager::new((*client).clone()));
406 Arc::new(WechatContext::new(client, token_manager))
407 }
408
409 #[test]
412 fn test_api_quota_response_parse() {
413 let json = r#"{
414 "quota": {
415 "daily_limit": 10000000,
416 "used": 500,
417 "remain": 9999500
418 },
419 "errcode": 0,
420 "errmsg": "ok"
421 }"#;
422
423 let response: ApiQuotaResponse = serde_json::from_str(json).unwrap();
424 assert_eq!(response.quota.daily_limit, 10_000_000);
425 assert_eq!(response.quota.used, 500);
426 assert_eq!(response.quota.remain, 9_999_500);
427 assert_eq!(response.errcode, 0);
428 }
429
430 #[test]
431 fn test_api_quota_response_missing_quota() {
432 let json = r#"{"errcode": 0, "errmsg": "ok"}"#;
433 let response: ApiQuotaResponse = serde_json::from_str(json).unwrap();
434 assert_eq!(response.quota.daily_limit, 0);
435 assert_eq!(response.quota.used, 0);
436 assert_eq!(response.quota.remain, 0);
437 }
438
439 #[test]
440 fn test_rid_info_response_parse() {
441 let json = r#"{
442 "request": {
443 "invoke_time": 1635927298,
444 "cost_in_ms": 100,
445 "request_url": "access_token=xxx",
446 "request_body": "{\"appid\":\"wx1234\"}",
447 "response_body": "{\"errcode\":0}",
448 "client_ip": "1.2.3.4"
449 },
450 "errcode": 0,
451 "errmsg": "ok"
452 }"#;
453
454 let response: RidInfoResponse = serde_json::from_str(json).unwrap();
455 assert_eq!(response.request.invoke_time, 1_635_927_298);
456 assert_eq!(response.request.cost_in_ms, 100);
457 assert_eq!(response.request.client_ip, "1.2.3.4");
458 assert_eq!(response.errcode, 0);
459 }
460
461 #[test]
462 fn test_rid_info_response_missing_request() {
463 let json = r#"{"errcode": 0, "errmsg": "ok"}"#;
464 let response: RidInfoResponse = serde_json::from_str(json).unwrap();
465 assert_eq!(response.request.invoke_time, 0);
466 assert!(response.request.client_ip.is_empty());
467 }
468
469 #[test]
470 fn test_callback_check_response_parse() {
471 let json = r#"{
472 "dns": [
473 {"ip": "1.2.3.4", "real_operator": "unicom"}
474 ],
475 "ping": [
476 {"ip": "1.2.3.4", "from_operator": "cap", "package_loss": "0%", "time": "20.536ms"}
477 ],
478 "errcode": 0,
479 "errmsg": "ok"
480 }"#;
481
482 let response: CallbackCheckResponse = serde_json::from_str(json).unwrap();
483 assert_eq!(response.dns.len(), 1);
484 assert_eq!(response.dns[0].ip, "1.2.3.4");
485 assert_eq!(response.dns[0].real_operator, "unicom");
486 assert_eq!(response.ping.len(), 1);
487 assert_eq!(response.ping[0].from_operator, "cap");
488 assert_eq!(response.ping[0].package_loss, "0%");
489 assert_eq!(response.ping[0].time, "20.536ms");
490 }
491
492 #[test]
493 fn test_callback_check_response_empty_arrays() {
494 let json = r#"{"errcode": 0, "errmsg": "ok"}"#;
495 let response: CallbackCheckResponse = serde_json::from_str(json).unwrap();
496 assert!(response.dns.is_empty());
497 assert!(response.ping.is_empty());
498 }
499
500 #[test]
501 fn test_ip_list_response_parse() {
502 let json = r#"{
503 "ip_list": ["101.226.62.77", "101.226.62.78", "101.226.62.79"],
504 "errcode": 0,
505 "errmsg": "ok"
506 }"#;
507
508 let response: IpListResponse = serde_json::from_str(json).unwrap();
509 assert_eq!(response.ip_list.len(), 3);
510 assert_eq!(response.ip_list[0], "101.226.62.77");
511 }
512
513 #[test]
514 fn test_ip_list_response_empty() {
515 let json = r#"{"errcode": 0, "errmsg": "ok"}"#;
516 let response: IpListResponse = serde_json::from_str(json).unwrap();
517 assert!(response.ip_list.is_empty());
518 }
519
520 #[test]
521 fn test_api_name() {
522 let context = create_test_context("http://localhost:0");
523 let api = OpenApiApi::new(context);
524 assert_eq!(api.api_name(), "openapi");
525 }
526
527 async fn setup_token_mock(mock_server: &wiremock::MockServer) {
530 use wiremock::matchers::{method, path};
531 use wiremock::{Mock, ResponseTemplate};
532
533 Mock::given(method("GET"))
534 .and(path("/cgi-bin/token"))
535 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
536 "access_token": "test_token",
537 "expires_in": 7200,
538 "errcode": 0,
539 "errmsg": ""
540 })))
541 .mount(mock_server)
542 .await;
543 }
544
545 #[tokio::test]
546 async fn test_clear_quota_success() {
547 use wiremock::matchers::{method, path, query_param};
548 use wiremock::{Mock, MockServer, ResponseTemplate};
549
550 let mock_server = MockServer::start().await;
551 setup_token_mock(&mock_server).await;
552
553 Mock::given(method("POST"))
554 .and(path("/cgi-bin/clear_quota"))
555 .and(query_param("access_token", "test_token"))
556 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
557 "errcode": 0,
558 "errmsg": "ok"
559 })))
560 .mount(&mock_server)
561 .await;
562
563 let context = create_test_context(&mock_server.uri());
564 let api = OpenApiApi::new(context);
565 let result = api.clear_quota().await;
566 assert!(result.is_ok());
567 }
568
569 #[tokio::test]
570 async fn test_clear_quota_api_error() {
571 use wiremock::matchers::{method, path};
572 use wiremock::{Mock, MockServer, ResponseTemplate};
573
574 let mock_server = MockServer::start().await;
575 setup_token_mock(&mock_server).await;
576
577 Mock::given(method("POST"))
578 .and(path("/cgi-bin/clear_quota"))
579 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
580 "errcode": 40013,
581 "errmsg": "invalid appid"
582 })))
583 .mount(&mock_server)
584 .await;
585
586 let context = create_test_context(&mock_server.uri());
587 let api = OpenApiApi::new(context);
588 let result = api.clear_quota().await;
589 assert!(result.is_err());
590 if let Err(WechatError::Api { code, message }) = result {
591 assert_eq!(code, 40013);
592 assert_eq!(message, "invalid appid");
593 } else {
594 panic!("Expected WechatError::Api");
595 }
596 }
597
598 #[tokio::test]
599 async fn test_get_api_quota_success() {
600 use wiremock::matchers::{method, path, query_param};
601 use wiremock::{Mock, MockServer, ResponseTemplate};
602
603 let mock_server = MockServer::start().await;
604 setup_token_mock(&mock_server).await;
605
606 Mock::given(method("POST"))
607 .and(path("/cgi-bin/openapi/quota/get"))
608 .and(query_param("access_token", "test_token"))
609 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
610 "quota": {
611 "daily_limit": 10000000,
612 "used": 500,
613 "remain": 9999500
614 },
615 "errcode": 0,
616 "errmsg": "ok"
617 })))
618 .mount(&mock_server)
619 .await;
620
621 let context = create_test_context(&mock_server.uri());
622 let api = OpenApiApi::new(context);
623 let result = api.get_api_quota("/cgi-bin/message/custom/send").await;
624 assert!(result.is_ok());
625 let response = result.unwrap();
626 assert_eq!(response.quota.daily_limit, 10_000_000);
627 assert_eq!(response.quota.used, 500);
628 }
629
630 #[tokio::test]
631 async fn test_clear_quota_by_app_secret_success() {
632 use wiremock::matchers::{method, path};
633 use wiremock::{Mock, MockServer, ResponseTemplate};
634
635 let mock_server = MockServer::start().await;
636 Mock::given(method("POST"))
639 .and(path("/cgi-bin/clear_quota/v2"))
640 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
641 "errcode": 0,
642 "errmsg": "ok"
643 })))
644 .mount(&mock_server)
645 .await;
646
647 let context = create_test_context(&mock_server.uri());
648 let api = OpenApiApi::new(context);
649 let result = api.clear_quota_by_app_secret().await;
650 assert!(result.is_ok());
651 }
652
653 #[tokio::test]
654 async fn test_get_rid_info_success() {
655 use wiremock::matchers::{method, path, query_param};
656 use wiremock::{Mock, MockServer, ResponseTemplate};
657
658 let mock_server = MockServer::start().await;
659 setup_token_mock(&mock_server).await;
660
661 Mock::given(method("POST"))
662 .and(path("/cgi-bin/openapi/rid/get"))
663 .and(query_param("access_token", "test_token"))
664 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
665 "request": {
666 "invoke_time": 1635927298,
667 "cost_in_ms": 100,
668 "request_url": "/cgi-bin/clear_quota",
669 "request_body": "",
670 "response_body": "{\"errcode\":0}",
671 "client_ip": "1.2.3.4"
672 },
673 "errcode": 0,
674 "errmsg": "ok"
675 })))
676 .mount(&mock_server)
677 .await;
678
679 let context = create_test_context(&mock_server.uri());
680 let api = OpenApiApi::new(context);
681 let result = api
682 .get_rid_info("61234567-abcd-1234-abcd-123456789012")
683 .await;
684 assert!(result.is_ok());
685 let response = result.unwrap();
686 assert_eq!(response.request.invoke_time, 1_635_927_298);
687 assert_eq!(response.request.cost_in_ms, 100);
688 assert_eq!(response.request.client_ip, "1.2.3.4");
689 }
690
691 #[tokio::test]
692 async fn test_callback_check_success() {
693 use wiremock::matchers::{method, path, query_param};
694 use wiremock::{Mock, MockServer, ResponseTemplate};
695
696 let mock_server = MockServer::start().await;
697 setup_token_mock(&mock_server).await;
698
699 Mock::given(method("POST"))
700 .and(path("/cgi-bin/callback/check"))
701 .and(query_param("access_token", "test_token"))
702 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
703 "dns": [{"ip": "1.2.3.4", "real_operator": "unicom"}],
704 "ping": [{"ip": "1.2.3.4", "from_operator": "cap", "package_loss": "0%", "time": "20.536ms"}],
705 "errcode": 0,
706 "errmsg": "ok"
707 })))
708 .mount(&mock_server)
709 .await;
710
711 let context = create_test_context(&mock_server.uri());
712 let api = OpenApiApi::new(context);
713 let result = api.callback_check("all", "DEFAULT").await;
714 assert!(result.is_ok());
715 let response = result.unwrap();
716 assert_eq!(response.dns.len(), 1);
717 assert_eq!(response.ping.len(), 1);
718 }
719
720 #[tokio::test]
721 async fn test_get_api_domain_ip_success() {
722 use wiremock::matchers::{method, path, query_param};
723 use wiremock::{Mock, MockServer, ResponseTemplate};
724
725 let mock_server = MockServer::start().await;
726 setup_token_mock(&mock_server).await;
727
728 Mock::given(method("GET"))
729 .and(path("/cgi-bin/get_api_domain_ip"))
730 .and(query_param("access_token", "test_token"))
731 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
732 "ip_list": ["101.226.62.77", "101.226.62.78"],
733 "errcode": 0,
734 "errmsg": "ok"
735 })))
736 .mount(&mock_server)
737 .await;
738
739 let context = create_test_context(&mock_server.uri());
740 let api = OpenApiApi::new(context);
741 let result = api.get_api_domain_ip().await;
742 assert!(result.is_ok());
743 let response = result.unwrap();
744 assert_eq!(response.ip_list.len(), 2);
745 assert_eq!(response.ip_list[0], "101.226.62.77");
746 }
747
748 #[tokio::test]
749 async fn test_get_callback_ip_success() {
750 use wiremock::matchers::{method, path, query_param};
751 use wiremock::{Mock, MockServer, ResponseTemplate};
752
753 let mock_server = MockServer::start().await;
754 setup_token_mock(&mock_server).await;
755
756 Mock::given(method("GET"))
757 .and(path("/cgi-bin/getcallbackip"))
758 .and(query_param("access_token", "test_token"))
759 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
760 "ip_list": ["101.226.103.61", "101.226.103.62"],
761 "errcode": 0,
762 "errmsg": "ok"
763 })))
764 .mount(&mock_server)
765 .await;
766
767 let context = create_test_context(&mock_server.uri());
768 let api = OpenApiApi::new(context);
769 let result = api.get_callback_ip().await;
770 assert!(result.is_ok());
771 let response = result.unwrap();
772 assert_eq!(response.ip_list.len(), 2);
773 assert_eq!(response.ip_list[0], "101.226.103.61");
774 }
775
776 #[test]
777 fn test_clear_quota_by_app_secret_request_debug_redacts_secret() {
778 let request = ClearQuotaByAppSecretRequest {
779 appid: "wx1234567890abcdef".to_string(),
780 appsecret: "top-secret-appsecret".to_string(),
781 };
782
783 let output = format!("{:?}", request);
784 assert!(output.contains("appsecret"));
785 assert!(output.contains("[REDACTED]"));
786 assert!(!output.contains("top-secret-appsecret"));
787 }
788}