Skip to main content

uapi_sdk_rust/
client.rs

1use crate::errors::{ApiErrorBody, Error, RateLimitPolicyEntry, RateLimitStateEntry, ResponseMeta};
2use crate::services::{
3    ClipzyZaiXianJianTieBanService, ConvertService, DailyService, GameService, ImageService,
4    MinGanCiShiBieService, MiscService, NetworkService, PoemService, RandomService, SocialService,
5    StatusService, TextService, TranslateService, WebparseService, ZhiNengSouSuoService,
6};
7use crate::Result;
8use once_cell::sync::Lazy;
9use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, RETRY_AFTER, USER_AGENT};
10use reqwest::StatusCode;
11use std::collections::BTreeMap;
12use std::sync::{Arc, RwLock};
13use std::time::Duration;
14use tracing::{debug, instrument};
15use url::Url;
16
17static DEFAULT_BASE: &str = "https://uapis.cn/";
18static DEFAULT_UA: &str = "uapi-sdk-rust/0.1.13";
19static DEFAULT_BASE_URL: Lazy<Url> =
20    Lazy::new(|| Url::parse(DEFAULT_BASE).expect("valid default base"));
21
22#[derive(Clone, Debug)]
23pub struct Client {
24    pub(crate) http: reqwest::Client,
25    pub(crate) base_url: Url,
26    pub(crate) api_key: Option<String>,
27    pub(crate) user_agent: String,
28    pub(crate) last_response_meta: Arc<RwLock<Option<ResponseMeta>>>,
29}
30
31impl Client {
32    pub fn new<T: Into<String>>(api_key: T) -> Self {
33        let http = reqwest::Client::builder()
34            .timeout(Duration::from_secs(20))
35            .build()
36            .expect("reqwest client");
37        Self {
38            http,
39            base_url: DEFAULT_BASE_URL.clone(),
40            api_key: Some(api_key.into()),
41            user_agent: DEFAULT_UA.to_string(),
42            last_response_meta: Arc::new(RwLock::new(None)),
43        }
44    }
45
46    pub fn from_env() -> Option<Self> {
47        let token = std::env::var("UAPI_TOKEN").ok()?;
48        let mut cli = Self::new(token);
49        if let Ok(base) = std::env::var("UAPI_BASE_URL") {
50            if let Ok(url) = Url::parse(&base) {
51                cli.base_url = normalize_base_url(url);
52            }
53        }
54        Some(cli)
55    }
56
57    pub fn builder() -> ClientBuilder {
58        ClientBuilder::default()
59    }
60
61    pub fn last_response_meta(&self) -> Option<ResponseMeta> {
62        self.last_response_meta
63            .read()
64            .ok()
65            .and_then(|guard| guard.clone())
66    }
67    pub fn clipzy_zai_xian_jian_tie_ban(&self) -> ClipzyZaiXianJianTieBanService<'_> {
68        ClipzyZaiXianJianTieBanService { client: self }
69    }
70    pub fn convert(&self) -> ConvertService<'_> {
71        ConvertService { client: self }
72    }
73    pub fn daily(&self) -> DailyService<'_> {
74        DailyService { client: self }
75    }
76    pub fn game(&self) -> GameService<'_> {
77        GameService { client: self }
78    }
79    pub fn image(&self) -> ImageService<'_> {
80        ImageService { client: self }
81    }
82    pub fn misc(&self) -> MiscService<'_> {
83        MiscService { client: self }
84    }
85    pub fn network(&self) -> NetworkService<'_> {
86        NetworkService { client: self }
87    }
88    pub fn poem(&self) -> PoemService<'_> {
89        PoemService { client: self }
90    }
91    pub fn random(&self) -> RandomService<'_> {
92        RandomService { client: self }
93    }
94    pub fn social(&self) -> SocialService<'_> {
95        SocialService { client: self }
96    }
97    pub fn status(&self) -> StatusService<'_> {
98        StatusService { client: self }
99    }
100    pub fn text(&self) -> TextService<'_> {
101        TextService { client: self }
102    }
103    pub fn translate(&self) -> TranslateService<'_> {
104        TranslateService { client: self }
105    }
106    pub fn webparse(&self) -> WebparseService<'_> {
107        WebparseService { client: self }
108    }
109    pub fn min_gan_ci_shi_bie(&self) -> MinGanCiShiBieService<'_> {
110        MinGanCiShiBieService { client: self }
111    }
112    pub fn zhi_neng_sou_suo(&self) -> ZhiNengSouSuoService<'_> {
113        ZhiNengSouSuoService { client: self }
114    }
115
116    #[instrument(skip(self, headers, query), fields(method=%method, path=%path))]
117    pub(crate) async fn request_json<T: serde::de::DeserializeOwned>(
118        &self,
119        method: reqwest::Method,
120        path: &str,
121        headers: Option<HeaderMap>,
122        query: Option<Vec<(String, String)>>,
123        json_body: Option<serde_json::Value>,
124    ) -> Result<T> {
125        let clean_path = path.trim_start_matches('/');
126        let url = self.base_url.join(clean_path)?;
127        let mut req = self.http.request(method.clone(), url.clone());
128
129        let mut merged = HeaderMap::new();
130        let user_agent = HeaderValue::from_str(&self.user_agent)
131            .unwrap_or_else(|_| HeaderValue::from_static(DEFAULT_UA));
132        merged.insert(USER_AGENT, user_agent);
133        if let Some(t) = &self.api_key {
134            let value = format!("Bearer {}", t);
135            if let Ok(h) = HeaderValue::from_str(&value) {
136                merged.insert(AUTHORIZATION, h);
137            }
138        }
139        if let Some(h) = headers {
140            merged.extend(h);
141        }
142        req = req.headers(merged);
143
144        if let Some(q) = query {
145            req = req.query(&q);
146        }
147        if let Some(body) = json_body {
148            req = req.json(&body);
149        }
150
151        debug!("request {}", url);
152        let resp = req.send().await?;
153        self.handle_json_response(resp).await
154    }
155
156    async fn handle_json_response<T: serde::de::DeserializeOwned>(
157        &self,
158        resp: reqwest::Response,
159    ) -> Result<T> {
160        let status = resp.status();
161        let meta = extract_response_meta(resp.headers());
162        if let Ok(mut guard) = self.last_response_meta.write() {
163            *guard = Some(meta.clone());
164        }
165        let req_id = meta.request_id.clone();
166        let retry_after = meta.retry_after_seconds;
167        if status.is_success() {
168            return Ok(resp.json::<T>().await?);
169        }
170        let text = resp.text().await.unwrap_or_default();
171        let parsed = serde_json::from_str::<ApiErrorBody>(&text).ok();
172        let msg = parsed
173            .as_ref()
174            .and_then(|b| b.message.clone())
175            .or_else(|| non_empty(text.clone()));
176        let code = parsed
177            .as_ref()
178            .and_then(|b| b.code.clone().or_else(|| b.error.clone()));
179        let details = parsed.as_ref().and_then(|b| {
180            b.details
181                .clone()
182                .or_else(|| b.quota.clone())
183                .or_else(|| b.docs.clone())
184        });
185        Err(map_status_to_error(
186            status,
187            code,
188            msg,
189            details,
190            req_id,
191            retry_after,
192            Some(meta),
193        ))
194    }
195}
196
197#[derive(Default)]
198pub struct ClientBuilder {
199    api_key: Option<String>,
200    base_url: Option<Url>,
201    timeout: Option<Duration>,
202    client: Option<reqwest::Client>,
203    user_agent: Option<String>,
204}
205
206impl ClientBuilder {
207    pub fn api_key<T: Into<String>>(mut self, api_key: T) -> Self {
208        self.api_key = Some(api_key.into());
209        self
210    }
211    pub fn base_url(mut self, base: Url) -> Self {
212        self.base_url = Some(normalize_base_url(base));
213        self
214    }
215    pub fn timeout(mut self, secs: u64) -> Self {
216        self.timeout = Some(Duration::from_secs(secs));
217        self
218    }
219    pub fn user_agent<T: Into<String>>(mut self, ua: T) -> Self {
220        self.user_agent = Some(ua.into());
221        self
222    }
223    pub fn http_client(mut self, cli: reqwest::Client) -> Self {
224        self.client = Some(cli);
225        self
226    }
227
228    pub fn build(self) -> Result<Client> {
229        let http = if let Some(cli) = self.client {
230            cli
231        } else {
232            reqwest::Client::builder()
233                .timeout(self.timeout.unwrap_or(Duration::from_secs(20)))
234                .build()?
235        };
236        Ok(Client {
237            http,
238            base_url: self.base_url.unwrap_or_else(|| DEFAULT_BASE_URL.clone()),
239            api_key: self.api_key,
240            user_agent: self.user_agent.unwrap_or_else(|| DEFAULT_UA.to_string()),
241            last_response_meta: Arc::new(RwLock::new(None)),
242        })
243    }
244}
245
246fn normalize_base_url(mut base: Url) -> Url {
247    let trimmed = base.path().trim_end_matches('/');
248    let without_api_prefix = trimmed.strip_suffix("/api/v1").unwrap_or(trimmed);
249    let normalized = without_api_prefix.trim_end_matches('/');
250    if normalized.is_empty() {
251        base.set_path("/");
252    } else {
253        base.set_path(&format!("{normalized}/"));
254    }
255    base
256}
257
258fn find_request_id(headers: &HeaderMap) -> Option<String> {
259    const CANDIDATES: &[&str] = &["x-request-id", "x-amzn-requestid", "traceparent"];
260    for key in CANDIDATES {
261        if let Some(v) = headers.get(*key) {
262            if let Ok(text) = v.to_str() {
263                return Some(text.to_string());
264            }
265        }
266    }
267    None
268}
269
270fn parse_retry_after(headers: &HeaderMap) -> Option<u64> {
271    headers
272        .get(RETRY_AFTER)
273        .and_then(|v| v.to_str().ok())
274        .and_then(|s| s.trim().parse::<u64>().ok())
275}
276
277fn non_empty(s: String) -> Option<String> {
278    let trimmed = s.trim();
279    if trimmed.is_empty() {
280        None
281    } else {
282        Some(trimmed.to_owned())
283    }
284}
285
286fn map_status_to_error(
287    status: StatusCode,
288    code: Option<String>,
289    message: Option<String>,
290    details: Option<serde_json::Value>,
291    request_id: Option<String>,
292    retry_after: Option<u64>,
293    meta: Option<ResponseMeta>,
294) -> Error {
295    let s = status.as_u16();
296    let normalized_code = code.clone().unwrap_or_default().to_uppercase();
297    match status {
298        StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Error::AuthenticationError {
299            status: s,
300            message,
301            request_id,
302            meta,
303        },
304        StatusCode::PAYMENT_REQUIRED
305            if normalized_code == "INSUFFICIENT_CREDITS" || normalized_code.is_empty() =>
306        {
307            Error::InsufficientCredits {
308                status: s,
309                message,
310                details,
311                request_id,
312                meta,
313            }
314        }
315        StatusCode::TOO_MANY_REQUESTS if normalized_code == "VISITOR_MONTHLY_QUOTA_EXHAUSTED" => {
316            Error::VisitorMonthlyQuotaExhausted {
317                status: s,
318                message,
319                details,
320                request_id,
321                meta,
322            }
323        }
324        StatusCode::TOO_MANY_REQUESTS => Error::RateLimitError {
325            status: s,
326            message,
327            retry_after_seconds: retry_after,
328            request_id,
329            meta,
330        },
331        StatusCode::NOT_FOUND => Error::NotFound {
332            status: s,
333            message,
334            request_id,
335            meta,
336        },
337        StatusCode::BAD_REQUEST => Error::ValidationError {
338            status: s,
339            message,
340            details,
341            request_id,
342            meta,
343        },
344        _ if status.is_server_error() => Error::ServerError {
345            status: s,
346            message,
347            request_id,
348            meta,
349        },
350        _ if status.is_client_error() => Error::ApiError {
351            status: s,
352            code,
353            message,
354            details,
355            request_id,
356            meta,
357        },
358        _ => Error::ApiError {
359            status: s,
360            code,
361            message,
362            details,
363            request_id,
364            meta,
365        },
366    }
367}
368
369fn extract_response_meta(headers: &HeaderMap) -> ResponseMeta {
370    let mut meta = ResponseMeta {
371        raw_headers: BTreeMap::new(),
372        ..Default::default()
373    };
374    for (name, value) in headers {
375        if let Ok(text) = value.to_str() {
376            meta.raw_headers
377                .insert(name.as_str().to_ascii_lowercase(), text.to_string());
378        }
379    }
380    meta.request_id = meta.raw_headers.get("x-request-id").cloned();
381    meta.retry_after_raw = meta.raw_headers.get("retry-after").cloned();
382    meta.retry_after_seconds = parse_retry_after(headers);
383    meta.debit_status = meta.raw_headers.get("uapi-debit-status").cloned();
384    meta.credits_requested = meta
385        .raw_headers
386        .get("uapi-credits-requested")
387        .and_then(|v| v.parse::<i64>().ok());
388    meta.credits_charged = meta
389        .raw_headers
390        .get("uapi-credits-charged")
391        .and_then(|v| v.parse::<i64>().ok());
392    meta.credits_pricing = meta.raw_headers.get("uapi-credits-pricing").cloned();
393    meta.active_quota_buckets = meta
394        .raw_headers
395        .get("uapi-quota-active-buckets")
396        .and_then(|v| v.parse::<u64>().ok());
397    meta.stop_on_empty = meta
398        .raw_headers
399        .get("uapi-stop-on-empty")
400        .and_then(|value| match value.trim().to_ascii_lowercase().as_str() {
401            "true" => Some(true),
402            "false" => Some(false),
403            _ => None,
404        });
405    meta.rate_limit_policy_raw = meta.raw_headers.get("ratelimit-policy").cloned();
406    meta.rate_limit_raw = meta.raw_headers.get("ratelimit").cloned();
407
408    for item in parse_structured_items(meta.rate_limit_policy_raw.as_deref()) {
409        let entry = RateLimitPolicyEntry {
410            name: item.name.clone(),
411            quota: item.params.get("q").and_then(|v| v.parse::<i64>().ok()),
412            unit: item.params.get("uapi-unit").cloned(),
413            window_seconds: item.params.get("w").and_then(|v| v.parse::<u64>().ok()),
414        };
415        meta.rate_limit_policies.insert(item.name, entry);
416    }
417    for item in parse_structured_items(meta.rate_limit_raw.as_deref()) {
418        let entry = RateLimitStateEntry {
419            name: item.name.clone(),
420            remaining: item.params.get("r").and_then(|v| v.parse::<i64>().ok()),
421            unit: item.params.get("uapi-unit").cloned(),
422            reset_after_seconds: item.params.get("t").and_then(|v| v.parse::<u64>().ok()),
423        };
424        meta.rate_limits.insert(item.name, entry);
425    }
426    meta.balance_limit_cents = meta
427        .rate_limit_policies
428        .get("billing-balance")
429        .and_then(|entry| entry.quota);
430    meta.balance_remaining_cents = meta
431        .rate_limits
432        .get("billing-balance")
433        .and_then(|entry| entry.remaining);
434    meta.quota_limit_credits = meta
435        .rate_limit_policies
436        .get("billing-quota")
437        .and_then(|entry| entry.quota);
438    meta.quota_remaining_credits = meta
439        .rate_limits
440        .get("billing-quota")
441        .and_then(|entry| entry.remaining);
442    meta.visitor_quota_limit_credits = meta
443        .rate_limit_policies
444        .get("visitor-quota")
445        .and_then(|entry| entry.quota);
446    meta.visitor_quota_remaining_credits = meta
447        .rate_limits
448        .get("visitor-quota")
449        .and_then(|entry| entry.remaining);
450    meta.billing_key_rate_limit = meta
451        .rate_limit_policies
452        .get("billing-key-rate")
453        .and_then(|entry| entry.quota);
454    meta.billing_key_rate_remaining = meta
455        .rate_limits
456        .get("billing-key-rate")
457        .and_then(|entry| entry.remaining);
458    meta.billing_key_rate_unit = meta
459        .rate_limit_policies
460        .get("billing-key-rate")
461        .and_then(|entry| entry.unit.clone())
462        .or_else(|| {
463            meta.rate_limits
464                .get("billing-key-rate")
465                .and_then(|entry| entry.unit.clone())
466        });
467    meta.billing_key_rate_window_seconds = meta
468        .rate_limit_policies
469        .get("billing-key-rate")
470        .and_then(|entry| entry.window_seconds);
471    meta.billing_key_rate_reset_after_seconds = meta
472        .rate_limits
473        .get("billing-key-rate")
474        .and_then(|entry| entry.reset_after_seconds);
475    meta.billing_ip_rate_limit = meta
476        .rate_limit_policies
477        .get("billing-ip-rate")
478        .and_then(|entry| entry.quota);
479    meta.billing_ip_rate_remaining = meta
480        .rate_limits
481        .get("billing-ip-rate")
482        .and_then(|entry| entry.remaining);
483    meta.billing_ip_rate_unit = meta
484        .rate_limit_policies
485        .get("billing-ip-rate")
486        .and_then(|entry| entry.unit.clone())
487        .or_else(|| {
488            meta.rate_limits
489                .get("billing-ip-rate")
490                .and_then(|entry| entry.unit.clone())
491        });
492    meta.billing_ip_rate_window_seconds = meta
493        .rate_limit_policies
494        .get("billing-ip-rate")
495        .and_then(|entry| entry.window_seconds);
496    meta.billing_ip_rate_reset_after_seconds = meta
497        .rate_limits
498        .get("billing-ip-rate")
499        .and_then(|entry| entry.reset_after_seconds);
500    meta.visitor_rate_limit = meta
501        .rate_limit_policies
502        .get("visitor-rate")
503        .and_then(|entry| entry.quota);
504    meta.visitor_rate_remaining = meta
505        .rate_limits
506        .get("visitor-rate")
507        .and_then(|entry| entry.remaining);
508    meta.visitor_rate_unit = meta
509        .rate_limit_policies
510        .get("visitor-rate")
511        .and_then(|entry| entry.unit.clone())
512        .or_else(|| {
513            meta.rate_limits
514                .get("visitor-rate")
515                .and_then(|entry| entry.unit.clone())
516        });
517    meta.visitor_rate_window_seconds = meta
518        .rate_limit_policies
519        .get("visitor-rate")
520        .and_then(|entry| entry.window_seconds);
521    meta.visitor_rate_reset_after_seconds = meta
522        .rate_limits
523        .get("visitor-rate")
524        .and_then(|entry| entry.reset_after_seconds);
525    meta
526}
527
528struct StructuredItem {
529    name: String,
530    params: BTreeMap<String, String>,
531}
532
533fn parse_structured_items(raw: Option<&str>) -> Vec<StructuredItem> {
534    raw.unwrap_or_default()
535        .split(',')
536        .filter_map(|chunk| {
537            let trimmed = chunk.trim();
538            if trimmed.is_empty() {
539                return None;
540            }
541            let mut segments = trimmed.split(';');
542            let name = unquote(segments.next()?);
543            let mut params = BTreeMap::new();
544            for segment in segments {
545                let part = segment.trim();
546                if let Some((key, value)) = part.split_once('=') {
547                    params.insert(key.trim().to_string(), unquote(value));
548                }
549            }
550            Some(StructuredItem { name, params })
551        })
552        .collect()
553}
554
555fn unquote(value: &str) -> String {
556    let trimmed = value.trim();
557    if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
558        trimmed[1..trimmed.len() - 1].to_string()
559    } else {
560        trimmed.to_string()
561    }
562}