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
23pub 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 pub(crate) fn bool_str(b: bool) -> &'static str {
107 if b { "true" } else { "false" }
108 }
109
110 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 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 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 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 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 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 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 pub fn delete_ok(&self, path: &str) -> Result<()> {
300 self.send_ok("DELETE", path, &[])
301 }
302
303 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 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 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 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 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 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 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}