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