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}