|
|
@@ -1 +1,128 @@
|
|
|
pub mod models;
|
|
|
+
|
|
|
+pub use models::*;
|
|
|
+use serde::de::DeserializeOwned;
|
|
|
+use std::sync::RwLock;
|
|
|
+
|
|
|
+#[derive(Debug, Clone)]
|
|
|
+pub enum ApiError {
|
|
|
+ Http(String),
|
|
|
+ Parse,
|
|
|
+ Api { code: i32, message: String },
|
|
|
+ EmptyData,
|
|
|
+}
|
|
|
+
|
|
|
+impl std::fmt::Display for ApiError {
|
|
|
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
+ match self {
|
|
|
+ ApiError::Http(m) => write!(f, "http: {m}"),
|
|
|
+ ApiError::Parse => write!(f, "响应解析失败"),
|
|
|
+ ApiError::Api { code, message } => write!(f, "服务错误 {code}: {message}"),
|
|
|
+ ApiError::EmptyData => write!(f, "响应 data 为空"),
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+pub struct ApiClient {
|
|
|
+ http: reqwest::Client,
|
|
|
+ base_url: RwLock<String>,
|
|
|
+ token: RwLock<String>,
|
|
|
+}
|
|
|
+
|
|
|
+impl ApiClient {
|
|
|
+ pub fn new(base_url: String, token: String) -> Self {
|
|
|
+ Self {
|
|
|
+ http: reqwest::Client::new(),
|
|
|
+ base_url: RwLock::new(base_url.trim_end_matches('/').to_string()),
|
|
|
+ token: RwLock::new(token),
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ pub fn update_token(&self, token: String) {
|
|
|
+ *self.token.write().unwrap() = token;
|
|
|
+ }
|
|
|
+
|
|
|
+ pub fn set_base_url(&self, base_url: String) {
|
|
|
+ *self.base_url.write().unwrap() = base_url.trim_end_matches('/').to_string();
|
|
|
+ }
|
|
|
+
|
|
|
+ pub fn parse_response<T: DeserializeOwned>(&self, body: &str) -> Result<T, ApiError> {
|
|
|
+ let result: ApiResult<T> = serde_json::from_str(body).map_err(|_| ApiError::Parse)?;
|
|
|
+ if !result.is_success() {
|
|
|
+ return Err(ApiError::Api {
|
|
|
+ code: result.code,
|
|
|
+ message: result.message.unwrap_or_default(),
|
|
|
+ });
|
|
|
+ }
|
|
|
+ result.data.ok_or(ApiError::EmptyData)
|
|
|
+ }
|
|
|
+
|
|
|
+ async fn post<T: DeserializeOwned>(&self, path: &str, body: Option<&serde_json::Value>) -> Result<T, ApiError> {
|
|
|
+ let url = format!("{}{}", *self.base_url.read().unwrap(), path);
|
|
|
+ let token = self.token.read().unwrap().clone();
|
|
|
+ let mut req = self.http.post(&url).bearer_auth(token);
|
|
|
+ if let Some(b) = body {
|
|
|
+ req = req.json(b);
|
|
|
+ }
|
|
|
+ let resp = req.send().await.map_err(|e| ApiError::Http(e.to_string()))?;
|
|
|
+ let text = resp.text().await.map_err(|e| ApiError::Http(e.to_string()))?;
|
|
|
+ self.parse_response(&text)
|
|
|
+ }
|
|
|
+
|
|
|
+ pub async fn tasks_today(&self) -> Result<Vec<CfcTask>, ApiError> {
|
|
|
+ self.post("/api/tasks/today", Some(&serde_json::json!({}))).await
|
|
|
+ }
|
|
|
+
|
|
|
+ pub async fn energy_overview(&self) -> Result<EnergyOverview, ApiError> {
|
|
|
+ self.post("/api/energy/overview", None).await
|
|
|
+ }
|
|
|
+
|
|
|
+ pub async fn daily_checkin(&self) -> Result<DailyCheckin, ApiError> {
|
|
|
+ self.post("/api/daily/checkin", None).await
|
|
|
+ }
|
|
|
+
|
|
|
+ pub async fn complete_water(&self) -> Result<serde_json::Value, ApiError> {
|
|
|
+ self.post("/api/daily/checkin", Some(&serde_json::json!({"type": "water"}))).await
|
|
|
+ }
|
|
|
+
|
|
|
+ pub async fn mascot_get(&self) -> Result<MascotInfo, ApiError> {
|
|
|
+ self.post("/api/user/mascot/get", None).await
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+#[cfg(test)]
|
|
|
+mod tests {
|
|
|
+ use super::*;
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn parse_success_response() {
|
|
|
+ let client = ApiClient::new("http://x".into(), "t".into());
|
|
|
+ let data: Vec<CfcTask> = client
|
|
|
+ .parse_response(r#"{"code":200,"data":[{"id":1,"title":"t","status":"PENDING","dueDate":"2026-08-15"}]}"#)
|
|
|
+ .unwrap();
|
|
|
+ assert_eq!(data.len(), 1);
|
|
|
+ assert_eq!(data[0].due_date.as_deref(), Some("2026-08-15"));
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn parse_error_code_returns_api_error() {
|
|
|
+ let client = ApiClient::new("http://x".into(), "t".into());
|
|
|
+ let err = client.parse_response::<serde_json::Value>(r#"{"code":401,"message":"unauthorized"}"#).unwrap_err();
|
|
|
+ match err {
|
|
|
+ ApiError::Api { code, .. } => assert_eq!(code, 401),
|
|
|
+ other => panic!("unexpected error: {other:?}"),
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn parse_malformed_returns_parse_error() {
|
|
|
+ let client = ApiClient::new("http://x".into(), "t".into());
|
|
|
+ assert!(matches!(client.parse_response::<serde_json::Value>("not json"), Err(ApiError::Parse)));
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn parse_empty_data_returns_empty_error() {
|
|
|
+ let client = ApiClient::new("http://x".into(), "t".into());
|
|
|
+ assert!(matches!(client.parse_response::<CfcTask>(r#"{"code":200}"#), Err(ApiError::EmptyData)));
|
|
|
+ }
|
|
|
+}
|