uapi_sdk_rust/
client.rs

1use crate::errors::{ApiErrorBody, Error};
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::time::Duration;
12use tracing::{debug, instrument};
13use url::Url;
14
15static DEFAULT_BASE: &str = "https://uapis.cn/api/v1/";
16static DEFAULT_UA: &str = "uapi-sdk-rust/0.1.3";
17static DEFAULT_BASE_URL: Lazy<Url> =
18    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}
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        }
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    pub fn clipzy_zai_xian_jian_tie_ban(&self) -> ClipzyZaiXianJianTieBanService<'_> {
57        ClipzyZaiXianJianTieBanService { client: self }
58    }
59    pub fn convert(&self) -> ConvertService<'_> {
60        ConvertService { client: self }
61    }
62    pub fn daily(&self) -> DailyService<'_> {
63        DailyService { client: self }
64    }
65    pub fn game(&self) -> GameService<'_> {
66        GameService { client: self }
67    }
68    pub fn image(&self) -> ImageService<'_> {
69        ImageService { client: self }
70    }
71    pub fn misc(&self) -> MiscService<'_> {
72        MiscService { client: self }
73    }
74    pub fn network(&self) -> NetworkService<'_> {
75        NetworkService { client: self }
76    }
77    pub fn poem(&self) -> PoemService<'_> {
78        PoemService { client: self }
79    }
80    pub fn random(&self) -> RandomService<'_> {
81        RandomService { client: self }
82    }
83    pub fn social(&self) -> SocialService<'_> {
84        SocialService { client: self }
85    }
86    pub fn status(&self) -> StatusService<'_> {
87        StatusService { client: self }
88    }
89    pub fn text(&self) -> TextService<'_> {
90        TextService { client: self }
91    }
92    pub fn translate(&self) -> TranslateService<'_> {
93        TranslateService { client: self }
94    }
95    pub fn webparse(&self) -> WebparseService<'_> {
96        WebparseService { client: self }
97    }
98    pub fn min_gan_ci_shi_bie(&self) -> MinGanCiShiBieService<'_> {
99        MinGanCiShiBieService { client: self }
100    }
101    pub fn zhi_neng_sou_suo(&self) -> ZhiNengSouSuoService<'_> {
102        ZhiNengSouSuoService { client: self }
103    }
104
105    #[instrument(skip(self, headers, query), fields(method=%method, path=%path))]
106    pub(crate) async fn request_json<T: serde::de::DeserializeOwned>(
107        &self,
108        method: reqwest::Method,
109        path: &str,
110        headers: Option<HeaderMap>,
111        query: Option<Vec<(String, String)>>,
112        json_body: Option<serde_json::Value>,
113    ) -> Result<T> {
114        let clean_path = path.trim_start_matches('/');
115        let url = self.base_url.join(clean_path)?;
116        let mut req = self.http.request(method.clone(), url.clone());
117
118        let mut merged = HeaderMap::new();
119        merged.insert(USER_AGENT, HeaderValue::from_static(DEFAULT_UA));
120        if let Some(t) = &self.api_key {
121            let value = format!("Bearer {}", t);
122            if let Ok(h) = HeaderValue::from_str(&value) {
123                merged.insert(AUTHORIZATION, h);
124            }
125        }
126        if let Some(h) = headers {
127            merged.extend(h);
128        }
129        req = req.headers(merged);
130
131        if let Some(q) = query {
132            req = req.query(&q);
133        }
134        if let Some(body) = json_body {
135            req = req.json(&body);
136        }
137
138        debug!("request {}", url);
139        let resp = req.send().await?;
140        self.handle_json_response(resp).await
141    }
142
143    async fn handle_json_response<T: serde::de::DeserializeOwned>(
144        &self,
145        resp: reqwest::Response,
146    ) -> Result<T> {
147        let status = resp.status();
148        let req_id = find_request_id(resp.headers());
149        let retry_after = parse_retry_after(resp.headers());
150        if status.is_success() {
151            return Ok(resp.json::<T>().await?);
152        }
153        let text = resp.text().await.unwrap_or_default();
154        let parsed = serde_json::from_str::<ApiErrorBody>(&text).ok();
155        let msg = parsed
156            .as_ref()
157            .and_then(|b| b.message.clone())
158            .or_else(|| non_empty(text.clone()));
159        let code = parsed.as_ref().and_then(|b| b.code.clone());
160        let details = parsed.as_ref().and_then(|b| b.details.clone());
161        Err(map_status_to_error(
162            status,
163            code,
164            msg,
165            details,
166            req_id,
167            retry_after,
168        ))
169    }
170}
171
172#[derive(Default)]
173pub struct ClientBuilder {
174    api_key: Option<String>,
175    base_url: Option<Url>,
176    timeout: Option<Duration>,
177    client: Option<reqwest::Client>,
178    user_agent: Option<String>,
179}
180
181impl ClientBuilder {
182    pub fn api_key<T: Into<String>>(mut self, api_key: T) -> Self {
183        self.api_key = Some(api_key.into());
184        self
185    }
186    pub fn base_url(mut self, base: Url) -> Self {
187        self.base_url = Some(base);
188        self
189    }
190    pub fn timeout(mut self, secs: u64) -> Self {
191        self.timeout = Some(Duration::from_secs(secs));
192        self
193    }
194    pub fn user_agent<T: Into<String>>(mut self, ua: T) -> Self {
195        self.user_agent = Some(ua.into());
196        self
197    }
198    pub fn http_client(mut self, cli: reqwest::Client) -> Self {
199        self.client = Some(cli);
200        self
201    }
202
203    pub fn build(self) -> Result<Client> {
204        let http = if let Some(cli) = self.client {
205            cli
206        } else {
207            reqwest::Client::builder()
208                .timeout(self.timeout.unwrap_or(Duration::from_secs(20)))
209                .build()?
210        };
211        Ok(Client {
212            http,
213            base_url: self.base_url.unwrap_or_else(|| DEFAULT_BASE_URL.clone()),
214            api_key: self.api_key,
215            user_agent: self.user_agent.unwrap_or_else(|| DEFAULT_UA.to_string()),
216        })
217    }
218}
219
220fn find_request_id(headers: &HeaderMap) -> Option<String> {
221    const CANDIDATES: &[&str] = &["x-request-id", "x-amzn-requestid", "traceparent"];
222    for key in CANDIDATES {
223        if let Some(v) = headers.get(*key) {
224            if let Ok(text) = v.to_str() {
225                return Some(text.to_string());
226            }
227        }
228    }
229    None
230}
231
232fn parse_retry_after(headers: &HeaderMap) -> Option<u64> {
233    headers
234        .get(RETRY_AFTER)
235        .and_then(|v| v.to_str().ok())
236        .and_then(|s| s.trim().parse::<u64>().ok())
237}
238
239fn non_empty(s: String) -> Option<String> {
240    let trimmed = s.trim();
241    if trimmed.is_empty() {
242        None
243    } else {
244        Some(trimmed.to_owned())
245    }
246}
247
248fn map_status_to_error(
249    status: StatusCode,
250    code: Option<String>,
251    message: Option<String>,
252    details: Option<serde_json::Value>,
253    request_id: Option<String>,
254    retry_after: Option<u64>,
255) -> Error {
256    let s = status.as_u16();
257    match status {
258        StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Error::AuthenticationError {
259            status: s,
260            message,
261            request_id,
262        },
263        StatusCode::TOO_MANY_REQUESTS => Error::RateLimitError {
264            status: s,
265            message,
266            retry_after_seconds: retry_after,
267            request_id,
268        },
269        StatusCode::NOT_FOUND => Error::NotFound {
270            status: s,
271            message,
272            request_id,
273        },
274        StatusCode::BAD_REQUEST => Error::ValidationError {
275            status: s,
276            message,
277            details,
278            request_id,
279        },
280        _ if status.is_server_error() => Error::ServerError {
281            status: s,
282            message,
283            request_id,
284        },
285        _ if status.is_client_error() => Error::ApiError {
286            status: s,
287            code,
288            message,
289            details,
290            request_id,
291        },
292        _ => Error::ApiError {
293            status: s,
294            code,
295            message,
296            details,
297            request_id,
298        },
299    }
300}