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 let link = headers.get(LINK_HEADER)?;
232 return Some(parse_link_headers(link));
233 }
234 None
235}
236
237#[derive(Clone, Debug, PartialEq)]
238pub struct Page {
239 pub url: String,
240 pub number: u32,
241}
242
243impl Page {
244 pub fn new(url: &str, number: u32) -> Self {
245 Page {
246 url: url.to_string(),
247 number,
248 }
249 }
250
251 pub fn url(&self) -> &str {
252 &self.url
253 }
254}
255
256pub const GITHUB_RATELIMIT_REMAINING: &str = "x-ratelimit-remaining";
259pub const GITHUB_RATELIMIT_RESET: &str = "x-ratelimit-reset";
260
261pub const RETRY_AFTER: &str = "retry-after";
264
265pub const GITLAB_RATELIMIT_REMAINING: &str = "ratelimit-remaining";
270pub const GITLAB_RATELIMIT_RESET: &str = "ratelimit-reset";
272
273#[derive(Clone, Copy, Debug, Default)]
281pub struct RateLimitHeader {
282 pub remaining: u32,
284 pub reset: Seconds,
286 pub retry_after: Seconds,
288}
289
290impl RateLimitHeader {
291 pub fn new(remaining: u32, reset: Seconds, retry_after: Seconds) -> Self {
292 RateLimitHeader {
293 remaining,
294 reset,
295 retry_after,
296 }
297 }
298}
299
300pub fn parse_ratelimit_headers(headers: Option<&Headers>) -> Option<RateLimitHeader> {
309 let mut ratelimit_header = RateLimitHeader::default();
310
311 if let Some(headers) = headers {
313 if let Some(retry_after) = headers.get(RETRY_AFTER) {
314 ratelimit_header.retry_after = Seconds::new(retry_after.parse::<u64>().unwrap_or(0));
315 }
316 if let Some(github_remaining) = headers.get(GITHUB_RATELIMIT_REMAINING) {
317 ratelimit_header.remaining = github_remaining.parse::<u32>().unwrap_or(0);
318 if let Some(github_reset) = headers.get(GITHUB_RATELIMIT_RESET) {
319 ratelimit_header.reset = Seconds::new(github_reset.parse::<u64>().unwrap_or(0));
320 }
321 log_info!("Header {}", ratelimit_header);
322 return Some(ratelimit_header);
323 }
324 if let Some(gitlab_remaining) = headers.get(GITLAB_RATELIMIT_REMAINING) {
325 ratelimit_header.remaining = gitlab_remaining.parse::<u32>().unwrap_or(0);
326 if let Some(gitlab_reset) = headers.get(GITLAB_RATELIMIT_RESET) {
327 ratelimit_header.reset = Seconds::new(gitlab_reset.parse::<u64>().unwrap_or(0));
328 }
329 log_info!("Header {}", ratelimit_header);
330 return Some(ratelimit_header);
331 }
332 }
333 None
334}
335
336impl Display for RateLimitHeader {
337 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
338 let reset = time::epoch_to_minutes_relative(self.reset);
339 write!(
340 f,
341 "RateLimitHeader: remaining: {}, reset in: {} minutes",
342 self.remaining, reset
343 )
344 }
345}
346
347#[derive(Clone, Debug, Default)]
348pub struct FlowControlHeaders {
349 page_header: Rc<Option<PageHeader>>,
350 rate_limit_header: Rc<Option<RateLimitHeader>>,
351}
352
353impl FlowControlHeaders {
354 pub fn new(
355 page_header: Rc<Option<PageHeader>>,
356 rate_limit_header: Rc<Option<RateLimitHeader>>,
357 ) -> Self {
358 FlowControlHeaders {
359 page_header,
360 rate_limit_header,
361 }
362 }
363
364 pub fn get_page_header(&self) -> Rc<Option<PageHeader>> {
365 self.page_header.clone()
366 }
367
368 pub fn get_rate_limit_header(&self) -> Rc<Option<RateLimitHeader>> {
369 self.rate_limit_header.clone()
370 }
371}
372
373#[cfg(test)]
374mod test {
375 use super::*;
376
377 #[test]
378 fn test_get_rate_limit_headers_github() {
379 let body = "responsebody";
380 let mut headers = Headers::new();
381 headers.set("x-ratelimit-remaining".to_string(), "30".to_string());
382 headers.set("x-ratelimit-reset".to_string(), "1658602270".to_string());
383 headers.set("retry-after".to_string(), "60".to_string());
384 let rate_limit_header = parse_ratelimit_headers(Some(&headers)).unwrap();
385 let flow_control_headers =
386 FlowControlHeaders::new(Rc::new(None), Rc::new(Some(rate_limit_header)));
387 let response = HttpResponse::builder()
388 .body(body.to_string())
389 .headers(headers)
390 .flow_control_headers(flow_control_headers)
391 .build()
392 .unwrap();
393 let ratelimit_headers = response.get_ratelimit_headers().unwrap();
394 assert_eq!(30, ratelimit_headers.remaining.clone());
395 assert_eq!(Seconds::new(1658602270), ratelimit_headers.reset);
396 assert_eq!(Seconds::new(60), ratelimit_headers.retry_after);
397 }
398
399 #[test]
400 fn test_get_rate_limit_headers_gitlab() {
401 let body = "responsebody";
402 let mut headers = Headers::new();
403 headers.set("ratelimit-remaining".to_string(), "30".to_string());
404 headers.set("ratelimit-reset".to_string(), "1658602270".to_string());
405 headers.set("retry-after".to_string(), "60".to_string());
406 let rate_limit_header = parse_ratelimit_headers(Some(&headers)).unwrap();
407 let flow_control_headers =
408 FlowControlHeaders::new(Rc::new(None), Rc::new(Some(rate_limit_header)));
409 let response = HttpResponse::builder()
410 .body(body.to_string())
411 .headers(headers)
412 .flow_control_headers(flow_control_headers)
413 .build()
414 .unwrap();
415 let ratelimit_headers = response.get_ratelimit_headers().unwrap();
416 assert_eq!(30, ratelimit_headers.remaining);
417 assert_eq!(Seconds::new(1658602270), ratelimit_headers.reset);
418 assert_eq!(Seconds::new(60), ratelimit_headers.retry_after);
419 }
420
421 #[test]
422 fn test_get_rate_limit_headers_camelcase_gitlab() {
423 let body = "responsebody";
424 let mut headers = Headers::new();
425 headers.set("RateLimit-remaining".to_string(), "30".to_string());
426 headers.set("rateLimit-reset".to_string(), "1658602270".to_string());
427 headers.set("Retry-After".to_string(), "60".to_string());
428 let rate_limit_header = parse_ratelimit_headers(Some(&headers));
429 let flow_control_headers =
430 FlowControlHeaders::new(Rc::new(None), Rc::new(rate_limit_header));
431 let response = HttpResponse::builder()
432 .body(body.to_string())
433 .headers(headers)
434 .flow_control_headers(flow_control_headers)
435 .build()
436 .unwrap();
437 let ratelimit_headers = response.get_ratelimit_headers();
438 assert!(ratelimit_headers.is_none());
439 }
440
441 #[test]
442 fn test_link_header_has_next_and_last_page() {
443 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""#;
444 let page_headers = parse_link_headers(link);
445 assert_eq!(
446 "https://api.github.com/search/code?q=addClass+user%3Amozilla&page=2",
447 page_headers.next.as_ref().unwrap().url
448 );
449 assert_eq!(2, page_headers.next.unwrap().number);
450 assert_eq!(
451 "https://api.github.com/search/code?q=addClass+user%3Amozilla&page=34",
452 page_headers.last.as_ref().unwrap().url
453 );
454 assert_eq!(34, page_headers.last.unwrap().number);
455 }
456
457 #[test]
458 fn test_link_header_has_no_next_page() {
459 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""#;
460 let page_headers = parse_link_headers(link);
461 assert_eq!(None, page_headers.next);
462 }
463
464 #[test]
465 fn test_link_header_has_first_next_and_last() {
466 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""#;
467 let page_headers = parse_link_headers(link);
468 assert_eq!(91, page_headers.last.unwrap().number);
469 assert_eq!(2, page_headers.next.unwrap().number);
470 }
471
472 #[test]
473 fn test_response_ok_status_get_request_200() {
474 assert!(HttpResponse::builder()
475 .status(200)
476 .build()
477 .unwrap()
478 .is_ok(&http::Method::GET));
479 }
480
481 #[test]
482 fn test_response_not_ok_if_get_request_400s() {
483 let not_ok_status = 400..=499;
484 for status in not_ok_status {
485 let response = HttpResponse::builder().status(status).build().unwrap();
486 assert!(!response.is_ok(&http::Method::GET));
487 }
488 }
489
490 #[test]
491 fn test_response_ok_status_post_request_201() {
492 assert!(HttpResponse::builder()
493 .status(201)
494 .build()
495 .unwrap()
496 .is_ok(&http::Method::POST));
497 }
498
499 #[test]
500 fn test_response_ok_if_post_request_409_422() {
501 let not_ok_status = [409, 422];
503 for status in not_ok_status.iter() {
504 let response = HttpResponse::builder().status(*status).build().unwrap();
505 assert!(response.is_ok(&http::Method::POST));
506 }
507 }
508
509 #[test]
510 fn test_response_not_ok_if_500s_any_case() {
511 let methods = [
512 http::Method::GET,
513 http::Method::POST,
514 http::Method::PATCH,
515 http::Method::PUT,
516 ];
517 let not_ok_status = 500..=599;
518 for status in not_ok_status {
519 for method in methods.iter() {
520 let response = HttpResponse::builder().status(status).build().unwrap();
521 assert!(!response.is_ok(method));
522 }
523 }
524 }
525
526 #[test]
527 fn test_link_headers_get_per_page_multiple_pages() {
528 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""#;
529 let page_headers = parse_link_headers(link);
530 assert_eq!(91, page_headers.last.unwrap().number);
531 assert_eq!(2, page_headers.next.unwrap().number);
532 assert_eq!(20, page_headers.per_page);
533 }
534
535 #[test]
536 fn test_link_headers_get_per_page_not_available_use_default() {
537 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""#;
538 let page_headers = parse_link_headers(link);
539 assert_eq!(91, page_headers.last.unwrap().number);
540 assert_eq!(2, page_headers.next.unwrap().number);
541 assert_eq!(api_defaults::DEFAULT_PER_PAGE, page_headers.per_page);
542 }
543
544 #[test]
545 fn test_link_headers_get_per_page_with_no_next_use_default() {
546 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""#;
547 let page_headers = parse_link_headers(link);
548 assert_eq!(91, page_headers.last.unwrap().number);
549 assert_eq!(None, page_headers.next);
550 assert_eq!(api_defaults::DEFAULT_PER_PAGE, page_headers.per_page);
551 }
552
553 #[test]
554 fn test_link_headers_get_per_page_available_in_last_only_use_default() {
555 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""#;
556 let page_headers = parse_link_headers(link);
557 assert_eq!(91, page_headers.last.unwrap().number);
558 assert_eq!(2, page_headers.next.unwrap().number);
559 assert_eq!(api_defaults::DEFAULT_PER_PAGE, page_headers.per_page);
560 }
561}