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