Skip to main content

gitee_cli_rs/api/
client.rs

1use crate::error::{GiteeError, Result};
2use super::search::Search;
3use crate::repo::Repo;
4
5use super::collaborators::Collaborators;
6use super::gists::Gists;
7use reqwest::blocking::Client as Http;
8use super::labels::Labels;
9use serde::de::DeserializeOwned;
10use super::milestones::Milestones;
11use serde_json::Value;
12use std::time::Duration;
13
14use super::{issues::Issues, pulls::Pulls, releases::Releases, repos::Repos, users::Users, webhooks::Webhooks};
15
16pub struct Client {
17    http: Http,
18    base: String,
19    token: String,
20    debug: bool,
21}
22
23/// Parameters for [`Client::raw`].
24pub struct RawRequest<'a> {
25    pub method: &'a str,
26    pub path: &'a str,
27    pub query: &'a [(&'a str, &'a str)],
28    pub form: &'a [(&'a str, &'a str)],
29    pub headers: &'a [(&'a str, &'a str)],
30    pub body: Option<&'a [u8]>,
31}
32
33impl Client {
34    pub fn new(base: String, token: String) -> Self {
35        let http = Http::builder()
36            .gzip(true)
37            .timeout(Duration::from_secs(30))
38            .user_agent("gitee-cli/0.1")
39            .build()
40            .expect("reqwest client");
41        Client {
42            http,
43            base,
44            token,
45            debug: false,
46        }
47    }
48
49    pub fn set_debug(&mut self, debug: bool) {
50        self.debug = debug;
51    }
52
53    pub fn for_host(host: &str, token: String) -> Self {
54        Self::new(format!("https://{host}/api/v5"), token)
55    }
56
57    pub fn pulls<'a>(&'a self, repo: &'a Repo) -> Pulls<'a> {
58        Pulls::new(self, repo)
59    }
60
61    pub fn issues<'a>(&'a self, repo: &'a Repo) -> Issues<'a> {
62        Issues::new(self, repo)
63    }
64
65    pub fn search<'a>(&'a self) -> Search<'a> {
66        Search::new(self)
67    }
68
69    pub fn releases<'a>(&'a self, repo: &'a Repo) -> Releases<'a> {
70        Releases::new(self, repo)
71    }
72
73    pub fn gists<'a>(&'a self) -> Gists<'a> {
74        Gists::new(self)
75    }
76
77    pub fn labels<'a>(&'a self, repo: &'a Repo) -> Labels<'a> {
78        Labels::new(self, repo)
79    }
80
81    pub fn repos<'a>(&'a self) -> Repos<'a> {
82        Repos::new(self)
83    }
84
85    pub fn milestones<'a>(&'a self, repo: &'a Repo) -> Milestones<'a> {
86        Milestones::new(self, repo)
87    }
88
89    pub fn users<'a>(&'a self) -> Users<'a> {
90        Users::new(self)
91    }
92
93    pub fn collaborators<'a>(&'a self, repo: &'a Repo) -> Collaborators<'a> {
94        Collaborators::new(self, repo)
95    }
96
97    pub fn webhooks<'a>(&'a self, repo: &'a Repo) -> Webhooks<'a> {
98        Webhooks::new(self, repo)
99    }
100
101    pub(crate) fn str_refs<K: AsRef<str>>(pairs: &[(K, String)]) -> Vec<(&str, &str)> {
102        pairs.iter().map(|(k, v)| (k.as_ref(), v.as_str())).collect()
103    }
104
105    /// Gitee form booleans are urlencoded as the strings `"true"`/`"false"`.
106    pub(crate) fn bool_str(b: bool) -> &'static str {
107        if b { "true" } else { "false" }
108    }
109
110    /// Gitee accepts `Authorization: token <T>`. Sending the token in the header
111    /// keeps it out of URLs/query strings, and therefore out of reqwest error
112    /// messages and server/proxy access logs.
113    fn auth(&self) -> String {
114        format!("token {}", self.token)
115    }
116
117    fn full(&self, path: &str) -> String {
118        format!("{}{}", self.base, path)
119    }
120
121    /// Map a non-2xx response onto a typed error. Gitee error bodies are JSON
122    /// envelopes like `{"message":"..."}`; we extract the human message so users
123    /// see something actionable instead of a raw JSON blob. 401 and 404 get
124    /// dedicated variants for clearer guidance.
125    fn check(
126        &self,
127        resp: reqwest::blocking::Response,
128        method: &str,
129        path: &str,
130    ) -> Result<reqwest::blocking::Response> {
131        let status = resp.status();
132        if status.is_success() {
133            return Ok(resp);
134        }
135        let code = status.as_u16();
136        let body = resp.text().unwrap_or_default();
137        let message = serde_json::from_str::<Value>(&body)
138            .ok()
139            .and_then(|v| {
140                v.get("message")
141                    .and_then(|m| m.as_str())
142                    .map(str::to_owned)
143                    .or_else(|| v.get("error").and_then(|m| m.as_str()).map(str::to_owned))
144            })
145            .unwrap_or_else(|| Self::trim_cap(&body, 300));
146        if self.debug {
147            eprintln!("<- {method} {path} -> {code}: {message}");
148        }
149        Err(match code {
150            401 => GiteeError::Unauthorized,
151            // Issue state PATCH footgun: wrong path / enterprise boards return
152            // 404 + {"message":"project or enterprise"}. Keep that as Api so
153            // Issues::edit/set_state can rewrite it; leave other 404s as NotFound.
154            404 if method.eq_ignore_ascii_case("PATCH")
155                && path.contains("/issues/")
156                && message.to_lowercase().contains("project or enterprise") =>
157            {
158                GiteeError::Api {
159                    status: 404,
160                    message,
161                }
162            }
163            404 => GiteeError::NotFound(path.to_string()),
164            429 => GiteeError::RateLimited(message),
165            _ => GiteeError::Api {
166                status: code,
167                message,
168            },
169        })
170    }
171
172    fn trace(&self, method: &str, path: &str) {
173        if self.debug {
174            eprintln!("-> {method} {path}");
175        }
176    }
177
178    /// Map a reqwest error to a typed GiteeError, recognizing connect/timeout
179    /// failures as `Network` so they map to exit code 6 instead of generic 1.
180    fn map_http_err(&self, e: reqwest::Error) -> GiteeError {
181        if e.is_connect() || e.is_timeout() {
182            GiteeError::Network(e.to_string())
183        } else {
184            GiteeError::Http(e)
185        }
186    }
187
188    pub fn get<T: DeserializeOwned>(&self, path: &str, query: &[(&str, &str)]) -> Result<T> {
189        self.trace("GET", path);
190        let resp = self
191            .http
192            .get(self.full(path))
193            .header("Authorization", self.auth())
194            .query(query)
195            .send()
196            .map_err(|e| self.map_http_err(e))?;
197        self.check(resp, "GET", path)?
198            .json()
199            .map_err(GiteeError::Http)
200    }
201
202    pub fn get_paged<T: DeserializeOwned>(
203        &self,
204        path: &str,
205        query: &[(&str, &str)],
206        limit: usize,
207    ) -> Result<Vec<T>> {
208        let mut out: Vec<T> = Vec::new();
209        let mut page = 1u32;
210        let per = 100;
211        while out.len() < limit {
212            let mut q: Vec<(&str, String)> =
213                vec![("page", page.to_string()), ("per_page", per.to_string())];
214            for (k, v) in query {
215                q.push((k, v.to_string()));
216            }
217            let qref: Vec<(&str, &str)> = q.iter().map(|(k, v)| (*k, v.as_str())).collect();
218            let chunk: Vec<T> = self.get(path, &qref)?;
219            let n = chunk.len();
220            out.extend(chunk);
221            if n < per {
222                break;
223            }
224            page += 1;
225        }
226        if out.len() > limit {
227            out.truncate(limit);
228        }
229        Ok(out)
230    }
231
232    pub fn post<T: DeserializeOwned>(&self, path: &str, form: &[(&str, &str)]) -> Result<T> {
233        self.send("POST", path, form)
234    }
235
236    pub fn patch<T: DeserializeOwned>(&self, path: &str, form: &[(&str, &str)]) -> Result<T> {
237        self.send("PATCH", path, form)
238    }
239
240    fn send<T: DeserializeOwned>(
241        &self,
242        method: &str,
243        path: &str,
244        form: &[(&str, &str)],
245    ) -> Result<T> {
246        self.trace(method, path);
247        let req = match method {
248            "POST" => self.http.post(self.full(path)),
249            "PATCH" => self.http.patch(self.full(path)),
250            _ => unreachable!(),
251        };
252        let resp = req.header("Authorization", self.auth()).form(form).send()
253            .map_err(|e| self.map_http_err(e))?;
254        self.check(resp, method, path)?
255            .json()
256            .map_err(GiteeError::Http)
257    }
258
259    /// Issue update requires a JSON body (Gitee rejects form encoding here).
260    pub fn patch_json<T: DeserializeOwned>(&self, path: &str, body: &Value) -> Result<T> {
261        self.trace("PATCH", path);
262        let resp = self
263            .http
264            .patch(self.full(path))
265            .header("Authorization", self.auth())
266            .json(body)
267            .send()
268            .map_err(|e| self.map_http_err(e))?;
269        self.check(resp, "PATCH", path)?
270            .json()
271            .map_err(GiteeError::Http)
272    }
273
274    /// POST with a JSON body (e.g. issue/PR label add: a JSON array of names).
275    pub fn post_json<T: DeserializeOwned>(&self, path: &str, body: &Value) -> Result<T> {
276        self.trace("POST", path);
277        let resp = self
278            .http
279            .post(self.full(path))
280            .header("Authorization", self.auth())
281            .json(body)
282            .send()
283            .map_err(|e| self.map_http_err(e))?;
284        self.check(resp, "POST", path)?
285            .json()
286            .map_err(GiteeError::Http)
287    }
288
289    /// For endpoints that return an empty body on success (e.g. PR review/merge).
290    pub fn post_ok(&self, path: &str, form: &[(&str, &str)]) -> Result<()> {
291        self.send_ok("POST", path, form)
292    }
293
294    pub fn put_ok(&self, path: &str, form: &[(&str, &str)]) -> Result<()> {
295        self.send_ok("PUT", path, form)
296    }
297
298    /// DELETE expecting an empty-body 2xx (204), e.g. gist/label/repo delete.
299    pub fn delete_ok(&self, path: &str) -> Result<()> {
300        self.send_ok("DELETE", path, &[])
301    }
302
303    /// DELETE with query parameters (e.g. PR assignees/testers removal).
304    /// Gitee encodes those memberships as query, not form body.
305    pub fn delete_ok_query(&self, path: &str, query: &[(&str, &str)]) -> Result<()> {
306        self.trace("DELETE", path);
307        let resp = self
308            .http
309            .delete(self.full(path))
310            .header("Authorization", self.auth())
311            .query(query)
312            .send()
313            .map_err(|e| self.map_http_err(e))?;
314        self.check(resp, "DELETE", path).map(|_| ())
315    }
316
317    /// GET expecting an empty-body 2xx (204), e.g. star/watch check endpoints.
318    pub fn get_ok(&self, path: &str) -> Result<()> {
319        self.trace("GET", path);
320        let resp = self
321            .http
322            .get(self.full(path))
323            .header("Authorization", self.auth())
324            .send()?;
325        self.check(resp, "GET", path).map(|_| ())
326    }
327
328    pub fn post_multipart<T: DeserializeOwned>(&self, path: &str, file_path: &str) -> Result<T> {
329        self.trace("POST", path);
330        let form = reqwest::blocking::multipart::Form::new()
331            .file("file", file_path)
332            .map_err(|e| GiteeError::Usage(format!("read file {file_path}: {e}")))?;
333        let resp = self
334            .http
335            .post(self.full(path))
336            .header("Authorization", self.auth())
337            .multipart(form)
338            .send()?;
339        self.check(resp, "POST", path)?
340            .json()
341            .map_err(GiteeError::Http)
342    }
343
344    /// GET an absolute URL. Public assets are fetched without auth first; on
345    /// 401 or 403 the request is retried with the Authorization header. reqwest
346    /// follows redirects and forwards headers — note that a redirect to a host
347    /// that rejects the forwarded token may still fail, so redirects are followed
348    /// manually with auth only on `gitee.com` hosts (excluding the CDN).
349    pub fn get_bytes(&self, url: &str) -> Result<Vec<u8>> {
350        let mut url = url.to_string();
351        let mut with_auth = false;
352        for _ in 0..8 {
353            if self.debug {
354                eprintln!(
355                    "-> GET {url}{}",
356                    if with_auth { " (auth)" } else { "" }
357                );
358            }
359            let mut req = self.http_no_redirect().get(&url);
360            if with_auth {
361                req = req.header("Authorization", self.auth());
362            }
363            let resp = req.send().map_err(GiteeError::Http)?;
364            let status = resp.status();
365            if status.is_success() {
366                return resp.bytes().map(|b| b.to_vec()).map_err(GiteeError::Http);
367            }
368            let code = status.as_u16();
369            if (code == 401 || code == 403) && !with_auth {
370                with_auth = true;
371                continue;
372            }
373            if status.is_redirection() {
374                let loc = resp
375                    .headers()
376                    .get(reqwest::header::LOCATION)
377                    .and_then(|v| v.to_str().ok())
378                    .ok_or_else(|| GiteeError::Api {
379                        status: code,
380                        message: "redirect response missing Location header".into(),
381                    })?;
382                url = Self::resolve_location(&url, loc);
383                with_auth = Self::asset_url_needs_auth(&url);
384                continue;
385            }
386            return self.bytes_or_api_error(resp);
387        }
388        Err(GiteeError::Api {
389            status: 0,
390            message: "too many redirects fetching asset".into(),
391        })
392    }
393
394    fn asset_url_needs_auth(url: &str) -> bool {
395        url.contains("gitee.com") && !url.contains("foruda.gitee.com")
396    }
397
398    fn resolve_location(base: &str, loc: &str) -> String {
399        if loc.starts_with("http://") || loc.starts_with("https://") {
400            return loc.to_string();
401        }
402        let base_url = reqwest::Url::parse(base).expect("asset base url");
403        base_url.join(loc).expect("redirect location").to_string()
404    }
405
406    fn http_no_redirect(&self) -> Http {
407        // Gitee's release asset endpoints reject the API user-agent on auth'd downloads.
408        Http::builder()
409            .gzip(true)
410            .timeout(Duration::from_secs(30))
411            .user_agent("curl/8.5.0")
412            .redirect(reqwest::redirect::Policy::none())
413            .build()
414            .expect("reqwest client")
415    }
416
417
418    fn bytes_or_api_error(&self, resp: reqwest::blocking::Response) -> Result<Vec<u8>> {
419        let status = resp.status();
420        if status.is_success() {
421            return resp.bytes().map(|b| b.to_vec()).map_err(GiteeError::Http);
422        }
423        let code = status.as_u16();
424        let body = resp.text().unwrap_or_default();
425        let message = Self::trim_cap(&body, 2048);
426        Err(GiteeError::Api {
427            status: code,
428            message,
429        })
430    }
431
432
433    fn send_ok(&self, method: &str, path: &str, form: &[(&str, &str)]) -> Result<()> {
434        self.trace(method, path);
435        let req = match method {
436            "POST" => self.http.post(self.full(path)),
437            "PUT" => self.http.put(self.full(path)),
438            "DELETE" => self.http.delete(self.full(path)),
439            _ => unreachable!(),
440        };
441        let resp = req.header("Authorization", self.auth()).form(form).send()
442            .map_err(|e| self.map_http_err(e))?;
443        self.check(resp, method, path).map(|_| ())
444    }
445
446    /// Char-safe truncation for error bodies (CJK messages would panic a
447    /// byte-slice cap like `&t[..max]`).
448    fn trim_cap(s: &str, max: usize) -> String {
449        let t = s.trim();
450        if t.chars().count() <= max {
451            return t.to_string();
452        }
453        format!("{}…", t.chars().take(max).collect::<String>())
454    }
455
456    /// Issue a raw API request and return the response body text.
457    pub fn raw(&self, req: &RawRequest<'_>) -> Result<String> {
458        self.trace(req.method, req.path);
459        let method = req.method.to_uppercase();
460        let url = self.full(req.path);
461
462        let mut rb = match method.as_str() {
463            "GET" => self.http.get(&url),
464            "POST" => self.http.post(&url),
465            "PUT" => self.http.put(&url),
466            "PATCH" => self.http.patch(&url),
467            "DELETE" => self.http.delete(&url),
468            "HEAD" => self.http.head(&url),
469            _ => unreachable!("method validated before raw()"),
470        };
471
472        rb = rb.header("Authorization", self.auth());
473
474        if matches!(method.as_str(), "GET" | "HEAD" | "DELETE") {
475            let mut q: Vec<(&str, &str)> = req.query.to_vec();
476            q.extend_from_slice(req.form);
477            if !q.is_empty() {
478                rb = rb.query(&q);
479            }
480        } else if let Some(body) = req.body {
481            let has_ct = req
482                .headers
483                .iter()
484                .any(|(k, _)| k.eq_ignore_ascii_case("content-type"));
485            if !has_ct {
486                rb = rb.header("Content-Type", "application/json");
487            }
488            rb = rb.body(body.to_vec());
489            if !req.query.is_empty() {
490                rb = rb.query(req.query);
491            }
492        } else if !req.form.is_empty() {
493            rb = rb.form(req.form);
494            if !req.query.is_empty() {
495                rb = rb.query(req.query);
496            }
497        } else if !req.query.is_empty() {
498            rb = rb.query(req.query);
499        }
500
501        for (k, v) in req.headers {
502            rb = rb.header(*k, *v);
503        }
504
505        let resp = rb.send()?;
506        let status = resp.status();
507        if status.is_success() {
508            return Ok(resp.text().unwrap_or_default());
509        }
510
511        let code = status.as_u16();
512        let body = resp.text().unwrap_or_default();
513        let message = Self::trim_cap(&body, 2048);
514        if self.debug {
515            eprintln!("<- {} {} -> {code}: {message}", req.method, req.path);
516        }
517        Err(GiteeError::Api {
518            status: code,
519            message,
520        })
521    }
522
523    /// GET-only pagination: walk `page`/`per_page=100` until a short page.
524    pub fn raw_paged(
525        &self,
526        path: &str,
527        query: &[(&str, &str)],
528        headers: &[(&str, &str)],
529    ) -> Result<Vec<Value>> {
530        let mut out: Vec<Value> = Vec::new();
531        let mut page = 1u32;
532        let per = 100;
533        loop {
534            let mut q: Vec<(String, String)> = vec![
535                ("page".into(), page.to_string()),
536                ("per_page".into(), per.to_string()),
537            ];
538            for (k, v) in query {
539                q.push((k.to_string(), v.to_string()));
540            }
541            let qref: Vec<(&str, &str)> = q.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
542            let body = self.raw(&RawRequest {
543                method: "GET",
544                path,
545                query: &qref,
546                form: &[],
547                headers,
548                body: None,
549            })?;
550            let parsed: Value = serde_json::from_str(&body).map_err(|e| {
551                GiteeError::Usage(format!("--paginate requires JSON array responses: {e}"))
552            })?;
553            let arr = parsed.as_array().ok_or_else(|| {
554                GiteeError::Usage("--paginate requires JSON array responses".into())
555            })?;
556            let n = arr.len();
557            out.extend(arr.iter().cloned());
558            if n < per {
559                break;
560            }
561            page += 1;
562        }
563        Ok(out)
564    }
565}