Skip to main content

uapi_sdk_rust/
client.rs

1use crate::errors::{ApiErrorBody, Error, RateLimitPolicyEntry, RateLimitStateEntry, ResponseMeta};
2use crate::services::{ClipzyZaiXianJianTieBanService,ConvertService,DailyService,GameService,ImageService,MiscService,NetworkService,PoemService,RandomService,SocialService,StatusService,TextService,TranslateService,WebparseService,MinGanCiShiBieService,ZhiNengSouSuoService
3};
4use crate::Result;
5use once_cell::sync::Lazy;
6use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, RETRY_AFTER, USER_AGENT};
7use reqwest::StatusCode;
8use std::collections::BTreeMap;
9use std::sync::{Arc, RwLock};
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11use tracing::{debug, instrument};
12use url::Url;
13
14static DEFAULT_BASE: &str = "https://uapis.cn/";
15static DEFAULT_UA: &str = "uapi-sdk-rust/0.1.0";
16static DEFAULT_BASE_URL: Lazy<Url> = Lazy::new(|| Url::parse(DEFAULT_BASE).expect("valid default base"));
17
18#[derive(Clone, Debug)]
19pub struct Client {
20    pub(crate) http: reqwest::Client,
21    pub(crate) base_url: Url,
22    pub(crate) api_key: Option<String>,
23    pub(crate) user_agent: String,
24    pub(crate) disable_cache_default: bool,
25    pub(crate) last_response_meta: Arc<RwLock<Option<ResponseMeta>>>,
26}
27
28impl Client {
29    pub fn new<T: Into<String>>(api_key: T) -> Self {
30        let http = reqwest::Client::builder()
31            .timeout(Duration::from_secs(20))
32            .build()
33            .expect("reqwest client");
34        Self {
35            http,
36            base_url: DEFAULT_BASE_URL.clone(),
37            api_key: Some(api_key.into()),
38            user_agent: DEFAULT_UA.to_string(),
39            disable_cache_default: false,
40            last_response_meta: Arc::new(RwLock::new(None)),
41        }
42    }
43
44    pub fn from_env() -> Option<Self> {
45        let token = std::env::var("UAPI_TOKEN").ok()?;
46        let mut cli = Self::new(token);
47        if let Ok(base) = std::env::var("UAPI_BASE_URL") {
48            if let Ok(url) = Url::parse(&base) {
49                cli.base_url = normalize_base_url(url);
50            }
51        }
52        Some(cli)
53    }
54
55    pub fn builder() -> ClientBuilder {
56        ClientBuilder::default()
57    }
58
59    pub fn last_response_meta(&self) -> Option<ResponseMeta> {
60        self.last_response_meta.read().ok().and_then(|guard| guard.clone())
61    }
62    pub fn clipzy_zai_xian_jian_tie_ban(&self) -> ClipzyZaiXianJianTieBanService<'_> {
63        ClipzyZaiXianJianTieBanService { client: self }
64    }
65    pub fn convert(&self) -> ConvertService<'_> {
66        ConvertService { client: self }
67    }
68    pub fn daily(&self) -> DailyService<'_> {
69        DailyService { client: self }
70    }
71    pub fn game(&self) -> GameService<'_> {
72        GameService { client: self }
73    }
74    pub fn image(&self) -> ImageService<'_> {
75        ImageService { client: self }
76    }
77    pub fn misc(&self) -> MiscService<'_> {
78        MiscService { client: self }
79    }
80    pub fn network(&self) -> NetworkService<'_> {
81        NetworkService { client: self }
82    }
83    pub fn poem(&self) -> PoemService<'_> {
84        PoemService { client: self }
85    }
86    pub fn random(&self) -> RandomService<'_> {
87        RandomService { client: self }
88    }
89    pub fn social(&self) -> SocialService<'_> {
90        SocialService { client: self }
91    }
92    pub fn status(&self) -> StatusService<'_> {
93        StatusService { client: self }
94    }
95    pub fn text(&self) -> TextService<'_> {
96        TextService { client: self }
97    }
98    pub fn translate(&self) -> TranslateService<'_> {
99        TranslateService { client: self }
100    }
101    pub fn webparse(&self) -> WebparseService<'_> {
102        WebparseService { client: self }
103    }
104    pub fn min_gan_ci_shi_bie(&self) -> MinGanCiShiBieService<'_> {
105        MinGanCiShiBieService { client: self }
106    }
107    pub fn zhi_neng_sou_suo(&self) -> ZhiNengSouSuoService<'_> {
108        ZhiNengSouSuoService { client: self }
109    }
110
111    #[instrument(skip(self, headers, query), fields(method=%method, path=%path))]
112    pub(crate) async fn request_json<T: serde::de::DeserializeOwned>(
113        &self,
114        method: reqwest::Method,
115        path: &str,
116        headers: Option<HeaderMap>,
117        query: Option<Vec<(String, String)>>,
118        json_body: Option<serde_json::Value>,
119        disable_cache: Option<bool>,
120    ) -> Result<T> {
121        let clean_path = path.trim_start_matches('/');
122        let url = self.base_url.join(clean_path)?;
123        let mut req = self.http.request(method.clone(), url.clone());
124
125        let mut merged = HeaderMap::new();
126        merged.insert(USER_AGENT, HeaderValue::from_static(DEFAULT_UA));
127        if let Some(t) = &self.api_key {
128            let value = format!("Bearer {}", t);
129            if let Ok(h) = HeaderValue::from_str(&value) {
130                merged.insert(AUTHORIZATION, h);
131            }
132        }
133        if let Some(h) = headers {
134            merged.extend(h);
135        }
136        req = req.headers(merged);
137
138        if let Some(q) = self.apply_cache_control(&method, query, disable_cache) {
139            req = req.query(&q);
140        }
141        if let Some(body) = json_body {
142            req = req.json(&body);
143        }
144
145        debug!("request {}", url);
146        let resp = req.send().await?;
147        self.handle_json_response(resp).await
148    }
149
150    async fn handle_json_response<T: serde::de::DeserializeOwned>(&self, resp: reqwest::Response) -> Result<T> {
151        let status = resp.status();
152        let meta = extract_response_meta(resp.headers());
153        if let Ok(mut guard) = self.last_response_meta.write() {
154            *guard = Some(meta.clone());
155        }
156        let req_id = meta.request_id.clone();
157        let retry_after = meta.retry_after_seconds;
158        if status.is_success() {
159            return Ok(resp.json::<T>().await?);
160        }
161        let text = resp.text().await.unwrap_or_default();
162        let parsed = serde_json::from_str::<ApiErrorBody>(&text).ok();
163        let msg = parsed.as_ref().and_then(|b| b.message.clone()).or_else(|| non_empty(text.clone()));
164        let code = parsed
165            .as_ref()
166            .and_then(|b| b.code.clone().or_else(|| b.error.clone()));
167        let details = parsed
168            .as_ref()
169            .and_then(|b| b.details.clone().or_else(|| b.quota.clone()).or_else(|| b.docs.clone()));
170        Err(map_status_to_error(status, code, msg, details, req_id, retry_after, Some(meta)))
171    }
172
173    fn apply_cache_control(
174        &self,
175        method: &reqwest::Method,
176        query: Option<Vec<(String, String)>>,
177        disable_cache: Option<bool>,
178    ) -> Option<Vec<(String, String)>> {
179        if *method != reqwest::Method::GET {
180            return query;
181        }
182        if let Some(items) = &query {
183            if items.iter().any(|(key, _)| key == "_t") {
184                return query;
185            }
186        }
187        let effective_disable_cache = disable_cache.unwrap_or(self.disable_cache_default);
188        if !effective_disable_cache {
189            return query;
190        }
191        let mut next = query.unwrap_or_default();
192        let now = SystemTime::now()
193            .duration_since(UNIX_EPOCH)
194            .unwrap_or_default()
195            .as_millis()
196            .to_string();
197        next.push(("_t".to_string(), now));
198        Some(next)
199    }
200}
201
202#[derive(Default)]
203pub struct ClientBuilder {
204    api_key: Option<String>,
205    base_url: Option<Url>,
206    timeout: Option<Duration>,
207    client: Option<reqwest::Client>,
208    user_agent: Option<String>,
209    disable_cache: Option<bool>,
210}
211
212impl ClientBuilder {
213    pub fn api_key<T: Into<String>>(mut self, api_key: T) -> Self { self.api_key = Some(api_key.into()); self }
214    pub fn base_url(mut self, base: Url) -> Self { self.base_url = Some(normalize_base_url(base)); self }
215    pub fn timeout(mut self, secs: u64) -> Self { self.timeout = Some(Duration::from_secs(secs)); self }
216    pub fn user_agent<T: Into<String>>(mut self, ua: T) -> Self { self.user_agent = Some(ua.into()); self }
217    pub fn http_client(mut self, cli: reqwest::Client) -> Self { self.client = Some(cli); self }
218    pub fn disable_cache(mut self, disable_cache: bool) -> Self { self.disable_cache = Some(disable_cache); self }
219
220    pub fn build(self) -> Result<Client> {
221        let http = if let Some(cli) = self.client {
222            cli
223        } else {
224            reqwest::Client::builder()
225                .timeout(self.timeout.unwrap_or(Duration::from_secs(20)))
226                .build()?
227        };
228        Ok(Client {
229            http,
230            base_url: self.base_url.unwrap_or_else(|| DEFAULT_BASE_URL.clone()),
231            api_key: self.api_key,
232            user_agent: self.user_agent.unwrap_or_else(|| DEFAULT_UA.to_string()),
233            disable_cache_default: self.disable_cache.unwrap_or(false),
234            last_response_meta: Arc::new(RwLock::new(None)),
235        })
236    }
237}
238
239fn normalize_base_url(mut base: Url) -> Url {
240    let trimmed = base.path().trim_end_matches('/');
241    let without_api_prefix = trimmed.strip_suffix("/api/v1").unwrap_or(trimmed);
242    let normalized = without_api_prefix.trim_end_matches('/');
243    if normalized.is_empty() {
244        base.set_path("/");
245    } else {
246        base.set_path(&format!("{normalized}/"));
247    }
248    base
249}
250
251fn find_request_id(headers: &HeaderMap) -> Option<String> {
252    const CANDIDATES: &[&str] = &["x-request-id", "x-amzn-requestid", "traceparent"];
253    for key in CANDIDATES {
254        if let Some(v) = headers.get(*key) {
255            if let Ok(text) = v.to_str() {
256                return Some(text.to_string());
257            }
258        }
259    }
260    None
261}
262
263fn parse_retry_after(headers: &HeaderMap) -> Option<u64> {
264    headers
265        .get(RETRY_AFTER)
266        .and_then(|v| v.to_str().ok())
267        .and_then(|s| s.trim().parse::<u64>().ok())
268}
269
270fn non_empty(s: String) -> Option<String> {
271    let trimmed = s.trim();
272    if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) }
273}
274
275fn map_status_to_error(
276    status: StatusCode,
277    code: Option<String>,
278    message: Option<String>,
279    details: Option<serde_json::Value>,
280    request_id: Option<String>,
281    retry_after: Option<u64>,
282    meta: Option<ResponseMeta>,
283) -> Error {
284    let s = status.as_u16();
285    let normalized_code = code.clone().unwrap_or_default().to_uppercase();
286    match status {
287        StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Error::AuthenticationError { status: s, message, request_id, meta },
288        StatusCode::PAYMENT_REQUIRED if normalized_code == "INSUFFICIENT_CREDITS" || normalized_code.is_empty() => Error::InsufficientCredits { status: s, message, details, request_id, meta },
289        StatusCode::TOO_MANY_REQUESTS if normalized_code == "VISITOR_MONTHLY_QUOTA_EXHAUSTED" => Error::VisitorMonthlyQuotaExhausted { status: s, message, details, request_id, meta },
290        StatusCode::TOO_MANY_REQUESTS => Error::RateLimitError { status: s, message, retry_after_seconds: retry_after, request_id, meta },
291        StatusCode::NOT_FOUND => Error::NotFound { status: s, message, request_id, meta },
292        StatusCode::BAD_REQUEST => Error::ValidationError { status: s, message, details, request_id, meta },
293        _ if status.is_server_error() => Error::ServerError { status: s, message, request_id, meta },
294        _ if status.is_client_error() => Error::ApiError { status: s, code, message, details, request_id, meta },
295        _ => Error::ApiError { status: s, code, message, details, request_id, meta },
296    }
297}
298
299fn extract_response_meta(headers: &HeaderMap) -> ResponseMeta {
300    let mut meta = ResponseMeta {
301        raw_headers: BTreeMap::new(),
302        ..Default::default()
303    };
304    for (name, value) in headers {
305        if let Ok(text) = value.to_str() {
306            meta.raw_headers.insert(name.as_str().to_ascii_lowercase(), text.to_string());
307        }
308    }
309    meta.request_id = meta.raw_headers.get("x-request-id").cloned();
310    meta.retry_after_seconds = parse_retry_after(headers);
311    meta.debit_status = meta.raw_headers.get("uapi-debit-status").cloned();
312    meta.credits_requested = meta.raw_headers.get("uapi-credits-requested").and_then(|v| v.parse::<i64>().ok());
313    meta.credits_charged = meta.raw_headers.get("uapi-credits-charged").and_then(|v| v.parse::<i64>().ok());
314    meta.credits_pricing = meta.raw_headers.get("uapi-credits-pricing").cloned();
315    meta.active_quota_buckets = meta.raw_headers.get("uapi-quota-active-buckets").and_then(|v| v.parse::<u64>().ok());
316    meta.stop_on_empty = meta.raw_headers.get("uapi-stop-on-empty").and_then(|value| match value.trim().to_ascii_lowercase().as_str() {
317        "true" => Some(true),
318        "false" => Some(false),
319        _ => None,
320    });
321    meta.rate_limit_policy_raw = meta.raw_headers.get("ratelimit-policy").cloned();
322    meta.rate_limit_raw = meta.raw_headers.get("ratelimit").cloned();
323
324    for item in parse_structured_items(meta.rate_limit_policy_raw.as_deref()) {
325        let entry = RateLimitPolicyEntry {
326            name: item.name.clone(),
327            quota: item.params.get("q").and_then(|v| v.parse::<i64>().ok()),
328            unit: item.params.get("uapi-unit").cloned(),
329            window_seconds: item.params.get("w").and_then(|v| v.parse::<u64>().ok()),
330        };
331        meta.rate_limit_policies.insert(item.name, entry);
332    }
333    for item in parse_structured_items(meta.rate_limit_raw.as_deref()) {
334        let entry = RateLimitStateEntry {
335            name: item.name.clone(),
336            remaining: item.params.get("r").and_then(|v| v.parse::<i64>().ok()),
337            unit: item.params.get("uapi-unit").cloned(),
338            reset_after_seconds: item.params.get("t").and_then(|v| v.parse::<u64>().ok()),
339        };
340        meta.rate_limits.insert(item.name, entry);
341    }
342    meta.balance_limit_cents = meta.rate_limit_policies.get("billing-balance").and_then(|entry| entry.quota);
343    meta.balance_remaining_cents = meta.rate_limits.get("billing-balance").and_then(|entry| entry.remaining);
344    meta.quota_limit_credits = meta.rate_limit_policies.get("billing-quota").and_then(|entry| entry.quota);
345    meta.quota_remaining_credits = meta.rate_limits.get("billing-quota").and_then(|entry| entry.remaining);
346    meta.visitor_quota_limit_credits = meta.rate_limit_policies.get("visitor-quota").and_then(|entry| entry.quota);
347    meta.visitor_quota_remaining_credits = meta.rate_limits.get("visitor-quota").and_then(|entry| entry.remaining);
348    meta
349}
350
351struct StructuredItem {
352    name: String,
353    params: BTreeMap<String, String>,
354}
355
356fn parse_structured_items(raw: Option<&str>) -> Vec<StructuredItem> {
357    raw.unwrap_or_default()
358        .split(',')
359        .filter_map(|chunk| {
360            let trimmed = chunk.trim();
361            if trimmed.is_empty() {
362                return None;
363            }
364            let mut segments = trimmed.split(';');
365            let name = unquote(segments.next()?);
366            let mut params = BTreeMap::new();
367            for segment in segments {
368                let part = segment.trim();
369                if let Some((key, value)) = part.split_once('=') {
370                    params.insert(key.trim().to_string(), unquote(value));
371                }
372            }
373            Some(StructuredItem { name, params })
374        })
375        .collect()
376}
377
378fn unquote(value: &str) -> String {
379    let trimmed = value.trim();
380    if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
381        trimmed[1..trimmed.len() - 1].to_string()
382    } else {
383        trimmed.to_string()
384    }
385}