1use crate::{
2 api_defaults,
3 cmds::{
4 merge_request::MergeRequestResponse,
5 project::{Member, Project},
6 },
7 http::{self, Headers, Request},
8 log_info,
9 remote::RemoteURL,
10 time::{self, Seconds},
11 Result,
12};
13use regex::Regex;
14use serde::Serialize;
15use std::{
16 ffi::OsStr,
17 fmt::{self, Display, Formatter},
18 rc::Rc,
19};
20
21pub trait TaskRunner {
26 type Response;
27 fn run<T>(&self, cmd: T) -> Result<Self::Response>
28 where
29 T: IntoIterator,
30 T::Item: AsRef<OsStr>;
31}
32
33pub trait HttpRunner {
38 type Response;
39 fn run<T: Serialize>(&self, cmd: &mut Request<T>) -> Result<Self::Response>;
40 fn api_max_pages<T: Serialize>(&self, cmd: &Request<T>) -> u32;
42}
43
44type Title = String;
45type Description = String;
46
47#[derive(Clone, Debug)]
48pub enum CmdInfo {
49 StatusModified(bool),
50 RemoteUrl(RemoteURL),
51 Branch(String),
52 CommitSummary(String),
53 CommitMessage(String),
54 CommitBody(Title, Description),
55 Project(Project),
56 Members(Vec<Member>),
57 MergeRequest(MergeRequestResponse),
58 MergeRequestsList(Vec<MergeRequestResponse>),
59 OutgoingCommits(String),
60 Ignore,
61 Exit,
62}
63
64#[derive(Clone, Debug, Builder)]
65pub struct ShellResponse {
66 #[builder(default)]
67 pub status: i32,
68 #[builder(default)]
69 pub body: String,
70}
71
72impl ShellResponse {
73 pub fn builder() -> ShellResponseBuilder {
74 ShellResponseBuilder::default()
75 }
76}
77
78#[derive(Clone, Debug, Builder)]
80pub struct HttpResponse {
81 #[builder(default)]
82 pub status: i32,
83 #[builder(default)]
84 pub body: String,
85 #[builder(setter(into, strip_option), default)]
87 pub headers: Option<Headers>,
88 #[builder(setter(into), default)]
89 pub flow_control_headers: FlowControlHeaders,
90 #[builder(setter(into), default)]
91 pub local_cache: bool,
92}
93
94impl HttpResponse {
95 pub fn builder() -> HttpResponseBuilder {
96 HttpResponseBuilder::default()
97 }
98}
99
100#[derive(Clone, Debug, PartialEq)]
101pub enum ResponseField {
102 Body,
103 Status,
104 Headers,
105}
106
107impl HttpResponse {
108 pub fn header(&self, key: &str) -> Option<&str> {
109 self.headers
110 .as_ref()
111 .and_then(|h| h.get(key))
112 .map(|s| s.as_str())
113 }
114
115 pub fn get_page_headers(&self) -> Rc<Option<PageHeader>> {
116 self.flow_control_headers.get_page_header()
117 }
118
119 pub fn get_ratelimit_headers(&self) -> Rc<Option<RateLimitHeader>> {
120 self.flow_control_headers.get_rate_limit_header()
121 }
122
123 pub fn get_flow_control_headers(&self) -> &FlowControlHeaders {
124 &self.flow_control_headers
125 }
126
127 pub fn get_etag(&self) -> Option<&str> {
128 self.header("etag")
129 }
130
131 pub fn is_ok(&self, method: &http::Method) -> bool {
132 match method {
133 http::Method::HEAD => self.status == 200,
134 http::Method::GET => self.status == 200,
135 http::Method::POST => {
136 self.status >= 200 && self.status < 300 || self.status == 409 || self.status == 422
137 }
138 http::Method::PATCH | http::Method::PUT => self.status >= 200 && self.status < 300,
139 }
140 }
141
142 pub fn update_rate_limit_headers(&mut self, headers: RateLimitHeader) {
143 self.flow_control_headers.rate_limit_header = Rc::new(Some(headers));
144 }
145}
146
147const NEXT: &str = "next";
148const LAST: &str = "last";
149pub const LINK_HEADER: &str = "link";
150
151fn parse_link_headers(link: &str) -> PageHeader {
152 lazy_static! {
153 static ref RE_URL: Regex = Regex::new(r#"<([^>]+)>;\s*rel="([^"]+)""#).unwrap();
154 static ref RE_PAGE_NUMBER: Regex = Regex::new(r"[^(per_)]page=(\d+)").unwrap();
155 static ref RE_PER_PAGE: Regex = Regex::new(r"per_page=(\d+)").unwrap();
156 }
157 let mut page_header = PageHeader::new();
158 'links: for cap in RE_URL.captures_iter(link) {
159 if cap.len() > 2 && &cap[2] == NEXT {
160 if let Some(per_page) = RE_PER_PAGE.captures(&cap[1]) {
163 if per_page.len() > 1 {
164 let per_page = per_page[1].to_string();
165 let per_page: u32 = per_page.parse().unwrap_or(api_defaults::DEFAULT_PER_PAGE);
166 page_header.per_page = per_page;
167 }
168 } else {
169 page_header.per_page = api_defaults::DEFAULT_PER_PAGE;
170 };
171 let url = cap[1].to_string();
172 if let Some(page_cap) = RE_PAGE_NUMBER.captures(&url) {
173 if page_cap.len() == 2 {
174 let page_number = page_cap[1].to_string();
175 let page_number: u32 = page_number.parse().unwrap_or(0);
176 let page = Page::new(&url, page_number);
177 page_header.set_next_page(page);
178 continue 'links;
179 }
180 }
181 }
182 if cap.len() > 2 && &cap[2] == LAST {
184 let url = cap[1].to_string();
185 if let Some(page_cap) = RE_PAGE_NUMBER.captures(&url) {
186 if page_cap.len() == 2 {
187 let page_number = page_cap[1].to_string();
188 let page_number: u32 = page_number.parse().unwrap_or(0);
189 let page = Page::new(&url, page_number);
190 page_header.set_last_page(page);
191 }
192 }
193 }
194 }
195 if page_header.per_page == 0 {
196 page_header.per_page = api_defaults::DEFAULT_PER_PAGE;
197 }
198 page_header
199}
200
201#[derive(Clone, Debug, Default)]
202pub struct PageHeader {
203 pub next: Option<Page>,
204 pub last: Option<Page>,
205 pub per_page: u32,
206}
207
208impl PageHeader {
209 pub fn new() -> Self {
210 Self::default()
211 }
212 pub fn set_next_page(&mut self, page: Page) {
213 self.next = Some(page);
214 }
215
216 pub fn set_last_page(&mut self, page: Page) {
217 self.last = Some(page);
218 }
219
220 pub fn next_page(&self) -> Option<&Page> {
221 self.next.as_ref()
222 }
223
224 pub fn last_page(&self) -> Option<&Page> {
225 self.last.as_ref()
226 }
227}
228
229pub fn parse_page_headers(headers: Option<&Headers>) -> Option<PageHeader> {
230 if let Some(headers) = headers {
231 match headers.get(LINK_HEADER) {
232 Some(link) => return Some(parse_link_headers(link)),
233 None => return None,
234 }
235 }
236 None
237}
238
239#[derive(Clone, Debug, PartialEq)]
240pub struct Page {
241 pub url: String,
242 pub number: u32,
243}
244
245impl Page {
246 pub fn new(url: &str, number: u32) -> Self {
247 Page {
248 url: url.to_string(),
249 number,
250 }
251 }
252
253 pub fn url(&self) -> &str {
254 &self.url
255 }
256}
257
258pub const GITHUB_RATELIMIT_REMAINING: &str = "x-ratelimit-remaining";
261pub const GITHUB_RATELIMIT_RESET: &str = "x-ratelimit-reset";
262
263pub const RETRY_AFTER: &str = "retry-after";
266
267pub const GITLAB_RATELIMIT_REMAINING: &str = "ratelimit-remaining";
272pub const GITLAB_RATELIMIT_RESET: &str = "ratelimit-reset";
274
275#[derive(Clone, Copy, Debug, Default)]
283pub struct RateLimitHeader {
284 pub remaining: u32,
286 pub reset: Seconds,
288 pub retry_after: Seconds,
290}
291
292impl RateLimitHeader {
293 pub fn new(remaining: u32, reset: Seconds, retry_after: Seconds) -> Self {
294 RateLimitHeader {
295 remaining,
296 reset,
297 retry_after,
298 }
299 }
300}
301
302pub fn parse_ratelimit_headers(headers: Option<&Headers>) -> Option<RateLimitHeader> {
311 let mut ratelimit_header = RateLimitHeader::default();
312
313 if let Some(headers) = headers {
315 if let Some(retry_after) = headers.get(RETRY_AFTER) {
316 ratelimit_header.retry_after = Seconds::new(retry_after.parse::<u64>().unwrap_or(0));
317 }
318 if let Some(github_remaining) = headers.get(GITHUB_RATELIMIT_REMAINING) {
319 ratelimit_header.remaining = github_remaining.parse::<u32>().unwrap_or(0);
320 if let Some(github_reset) = headers.get(GITHUB_RATELIMIT_RESET) {
321 ratelimit_header.reset = Seconds::new(github_reset.parse::<u64>().unwrap_or(0));
322 }
323 log_info!("Header {}", ratelimit_header);
324 return Some(ratelimit_header);
325 }
326 if let Some(gitlab_remaining) = headers.get(GITLAB_RATELIMIT_REMAINING) {
327 ratelimit_header.remaining = gitlab_remaining.parse::<u32>().unwrap_or(0);
328 if let Some(gitlab_reset) = headers.get(GITLAB_RATELIMIT_RESET) {
329 ratelimit_header.reset = Seconds::new(gitlab_reset.parse::<u64>().unwrap_or(0));
330 }
331 log_info!("Header {}", ratelimit_header);
332 return Some(ratelimit_header);
333 }
334 }
335 None
336}
337
338impl Display for RateLimitHeader {
339 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
340 let reset = time::epoch_to_minutes_relative(self.reset);
341 write!(
342 f,
343 "RateLimitHeader: remaining: {}, reset in: {} minutes",
344 self.remaining, reset
345 )
346 }
347}
348
349#[derive(Clone, Debug, Default)]
350pub struct FlowControlHeaders {
351 page_header: Rc<Option<PageHeader>>,
352 rate_limit_header: Rc<Option<RateLimitHeader>>,
353}
354
355impl FlowControlHeaders {
356 pub fn new(
357 page_header: Rc<Option<PageHeader>>,
358 rate_limit_header: Rc<Option<RateLimitHeader>>,
359 ) -> Self {
360 FlowControlHeaders {
361 page_header,
362 rate_limit_header,
363 }
364 }
365
366 pub fn get_page_header(&self) -> Rc<Option<PageHeader>> {
367 self.page_header.clone()
368 }
369
370 pub fn get_rate_limit_header(&self) -> Rc<Option<RateLimitHeader>> {
371 self.rate_limit_header.clone()
372 }
373}
374
375#[cfg(test)]
376mod test {
377 use super::*;
378
379 #[test]
380 fn test_get_rate_limit_headers_github() {
381 let body = "responsebody";
382 let mut headers = Headers::new();
383 headers.set("x-ratelimit-remaining".to_string(), "30".to_string());
384 headers.set("x-ratelimit-reset".to_string(), "1658602270".to_string());
385 headers.set("retry-after".to_string(), "60".to_string());
386 let rate_limit_header = parse_ratelimit_headers(Some(&headers)).unwrap();
387 let flow_control_headers =
388 FlowControlHeaders::new(Rc::new(None), Rc::new(Some(rate_limit_header)));
389 let response = HttpResponse::builder()
390 .body(body.to_string())
391 .headers(headers)
392 .flow_control_headers(flow_control_headers)
393 .build()
394 .unwrap();
395 let ratelimit_headers = response.get_ratelimit_headers().unwrap();
396 assert_eq!(30, ratelimit_headers.remaining.clone());
397 assert_eq!(Seconds::new(1658602270), ratelimit_headers.reset);
398 assert_eq!(Seconds::new(60), ratelimit_headers.retry_after);
399 }
400
401 #[test]
402 fn test_get_rate_limit_headers_gitlab() {
403 let body = "responsebody";
404 let mut headers = Headers::new();
405 headers.set("ratelimit-remaining".to_string(), "30".to_string());
406 headers.set("ratelimit-reset".to_string(), "1658602270".to_string());
407 headers.set("retry-after".to_string(), "60".to_string());
408 let rate_limit_header = parse_ratelimit_headers(Some(&headers)).unwrap();
409 let flow_control_headers =
410 FlowControlHeaders::new(Rc::new(None), Rc::new(Some(rate_limit_header)));
411 let response = HttpResponse::builder()
412 .body(body.to_string())
413 .headers(headers)
414 .flow_control_headers(flow_control_headers)
415 .build()
416 .unwrap();
417 let ratelimit_headers = response.get_ratelimit_headers().unwrap();
418 assert_eq!(30, ratelimit_headers.remaining);
419 assert_eq!(Seconds::new(1658602270), ratelimit_headers.reset);
420 assert_eq!(Seconds::new(60), ratelimit_headers.retry_after);
421 }
422
423 #[test]
424 fn test_get_rate_limit_headers_camelcase_gitlab() {
425 let body = "responsebody";
426 let mut headers = Headers::new();
427 headers.set("RateLimit-remaining".to_string(), "30".to_string());
428 headers.set("rateLimit-reset".to_string(), "1658602270".to_string());
429 headers.set("Retry-After".to_string(), "60".to_string());
430 let rate_limit_header = parse_ratelimit_headers(Some(&headers));
431 let flow_control_headers =
432 FlowControlHeaders::new(Rc::new(None), Rc::new(rate_limit_header));
433 let response = HttpResponse::builder()
434 .body(body.to_string())
435 .headers(headers)
436 .flow_control_headers(flow_control_headers)
437 .build()
438 .unwrap();
439 let ratelimit_headers = response.get_ratelimit_headers();
440 assert!(ratelimit_headers.is_none());
441 }
442
443 #[test]
444 fn test_link_header_has_next_and_last_page() {
445 let link = r#"<https://api.github.com/search/code?q=addClass+user%3Amozilla&page=2>; rel="next", <https://api.github.com/search/code?q=addClass+user%3Amozilla&page=34>; rel="last""#;
446 let page_headers = parse_link_headers(link);
447 assert_eq!(
448 "https://api.github.com/search/code?q=addClass+user%3Amozilla&page=2",
449 page_headers.next.as_ref().unwrap().url
450 );
451 assert_eq!(2, page_headers.next.unwrap().number);
452 assert_eq!(
453 "https://api.github.com/search/code?q=addClass+user%3Amozilla&page=34",
454 page_headers.last.as_ref().unwrap().url
455 );
456 assert_eq!(34, page_headers.last.unwrap().number);
457 }
458
459 #[test]
460 fn test_link_header_has_no_next_page() {
461 let link = r#"<http://gitlab-web/api/v4/projects/tooling%2Fcli/members/all?id=tooling%2Fcli&page=1&per_page=20>; rel="first", <http://gitlab-web/api/v4/projects/tooling%2Fcli/members/all?id=tooling%2Fcli&page=1&per_page=20>; rel="last""#;
462 let page_headers = parse_link_headers(link);
463 assert_eq!(None, page_headers.next);
464 }
465
466 #[test]
467 fn test_link_header_has_first_next_and_last() {
468 let link = r#"<https://gitlab-web/api/v4/projects/15/pipelines?id=15&order_by=id&page=2&per_page=20&sort=desc>; rel="next", <https://gitlab-web/api/v4/projects/15/pipelines?id=15&order_by=id&page=1&per_page=20&sort=desc>; rel="first", <https://gitlab-web/api/v4/projects/15/pipelines?id=15&order_by=id&page=91&per_page=20&sort=desc>; rel="last""#;
469 let page_headers = parse_link_headers(link);
470 assert_eq!(91, page_headers.last.unwrap().number);
471 assert_eq!(2, page_headers.next.unwrap().number);
472 }
473
474 #[test]
475 fn test_response_ok_status_get_request_200() {
476 assert!(HttpResponse::builder()
477 .status(200)
478 .build()
479 .unwrap()
480 .is_ok(&http::Method::GET));
481 }
482
483 #[test]
484 fn test_response_not_ok_if_get_request_400s() {
485 let not_ok_status = 400..=499;
486 for status in not_ok_status {
487 let response = HttpResponse::builder().status(status).build().unwrap();
488 assert!(!response.is_ok(&http::Method::GET));
489 }
490 }
491
492 #[test]
493 fn test_response_ok_status_post_request_201() {
494 assert!(HttpResponse::builder()
495 .status(201)
496 .build()
497 .unwrap()
498 .is_ok(&http::Method::POST));
499 }
500
501 #[test]
502 fn test_response_ok_if_post_request_409_422() {
503 let not_ok_status = [409, 422];
505 for status in not_ok_status.iter() {
506 let response = HttpResponse::builder().status(*status).build().unwrap();
507 assert!(response.is_ok(&http::Method::POST));
508 }
509 }
510
511 #[test]
512 fn test_response_not_ok_if_500s_any_case() {
513 let methods = [
514 http::Method::GET,
515 http::Method::POST,
516 http::Method::PATCH,
517 http::Method::PUT,
518 ];
519 let not_ok_status = 500..=599;
520 for status in not_ok_status {
521 for method in methods.iter() {
522 let response = HttpResponse::builder().status(status).build().unwrap();
523 assert!(!response.is_ok(method));
524 }
525 }
526 }
527
528 #[test]
529 fn test_link_headers_get_per_page_multiple_pages() {
530 let link = r#"<https://gitlab-web/api/v4/projects/15/pipelines?id=15&order_by=id&page=2&per_page=20&sort=desc>; rel="next", <https://gitlab-web/api/v4/projects/15/pipelines?id=15&order_by=id&page=1&per_page=20&sort=desc>; rel="first", <https://gitlab-web/api/v4/projects/15/pipelines?id=15&order_by=id&page=91&per_page=20&sort=desc>; rel="last""#;
531 let page_headers = parse_link_headers(link);
532 assert_eq!(91, page_headers.last.unwrap().number);
533 assert_eq!(2, page_headers.next.unwrap().number);
534 assert_eq!(20, page_headers.per_page);
535 }
536
537 #[test]
538 fn test_link_headers_get_per_page_not_available_use_default() {
539 let link = r#"<https://gitlab-web/api/v4/projects/15/pipelines?id=15&order_by=id&page=2&sort=desc>; rel="next", <https://gitlab-web/api/v4/projects/15/pipelines?id=15&order_by=id&page=1&sort=desc>; rel="first", <https://gitlab-web/api/v4/projects/15/pipelines?id=15&order_by=id&page=91&sort=desc>; rel="last""#;
540 let page_headers = parse_link_headers(link);
541 assert_eq!(91, page_headers.last.unwrap().number);
542 assert_eq!(2, page_headers.next.unwrap().number);
543 assert_eq!(api_defaults::DEFAULT_PER_PAGE, page_headers.per_page);
544 }
545
546 #[test]
547 fn test_link_headers_get_per_page_with_no_next_use_default() {
548 let link = r#"<https://gitlab-web/api/v4/projects/15/pipelines?id=15&order_by=id&page=1&sort=desc>; rel="first", <https://gitlab-web/api/v4/projects/15/pipelines?id=15&order_by=id&page=91&sort=desc>; rel="last""#;
549 let page_headers = parse_link_headers(link);
550 assert_eq!(91, page_headers.last.unwrap().number);
551 assert_eq!(None, page_headers.next);
552 assert_eq!(api_defaults::DEFAULT_PER_PAGE, page_headers.per_page);
553 }
554
555 #[test]
556 fn test_link_headers_get_per_page_available_in_last_only_use_default() {
557 let link = r#"<https://gitlab-web/api/v4/projects/15/pipelines?id=15&order_by=id&page=2&sort=desc>; rel="next", <https://gitlab-web/api/v4/projects/15/pipelines?id=15&order_by=id&per_page=20&page=91&sort=desc>; rel="last""#;
558 let page_headers = parse_link_headers(link);
559 assert_eq!(91, page_headers.last.unwrap().number);
560 assert_eq!(2, page_headers.next.unwrap().number);
561 assert_eq!(api_defaults::DEFAULT_PER_PAGE, page_headers.per_page);
562 }
563}