gr/
io.rs

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
21/// A trait that handles the execution of processes with a finite lifetime. For
22/// example, it can be an in-memory process for testing or a shell command doing
23/// I/O. It handles all processes that do not conform with the HTTP protocol.
24/// For that, check the `HttpRunner`
25pub 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
33/// A trait for the HTTP protocol. Implementers need to conform with the HTTP
34/// constraints and requirements. Implementers accept a `Request` that wraps
35/// headers, payloads and HTTP methods. Clients can potentially do HTTP calls
36/// against a remote server or mock the responses for testing purposes.
37pub trait HttpRunner {
38    type Response;
39    fn run<T: Serialize>(&self, cmd: &mut Request<T>) -> Result<Self::Response>;
40    /// Return the number of API MAX PAGES allowed for the given Request.
41    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/// Adapts lower level I/O HTTP/Shell outputs to a common Response.
79#[derive(Clone, Debug, Builder)]
80pub struct HttpResponse {
81    #[builder(default)]
82    pub status: i32,
83    #[builder(default)]
84    pub body: String,
85    /// Optional headers. Mostly used by HTTP downstream HTTP responses
86    #[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            // Capture per_page in next page if available to avoid re-computing
161            // this section in next matches like `first` and `last`
162            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        // TODO pull code out - return a page and its type next or last.
183        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
258// https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#exceeding-the-rate-limit
259
260pub const GITHUB_RATELIMIT_REMAINING: &str = "x-ratelimit-remaining";
261pub const GITHUB_RATELIMIT_RESET: &str = "x-ratelimit-reset";
262
263// Time to wait before retrying the next request - standard common header
264// Gitlab Docs: Retry-After
265pub const RETRY_AFTER: &str = "retry-after";
266
267// https://docs.gitlab.com/ee/administration/settings/user_and_ip_rate_limits.html
268
269// Internal processing is all in lowercase
270// Docs: RateLimit-Remaining
271pub const GITLAB_RATELIMIT_REMAINING: &str = "ratelimit-remaining";
272// Docs: RateLimit-Reset
273pub const GITLAB_RATELIMIT_RESET: &str = "ratelimit-reset";
274
275/// Unifies the different ratelimit headers available from the different remotes.
276/// Github API ratelimit headers:
277/// remaining: x-ratelimit-remaining
278/// reset: x-ratelimit-reset
279/// Gitlab API ratelimit headers:
280/// remaining: RateLimit-Remaining
281/// reset: RateLimit-Reset
282#[derive(Clone, Copy, Debug, Default)]
283pub struct RateLimitHeader {
284    // The number of requests remaining in the current rate limit window.
285    pub remaining: u32,
286    // Unix time-formatted time when the request quota is reset.
287    pub reset: Seconds,
288    // Time to wait before retrying the next request
289    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
302// Defaults:
303// https://docs.gitlab.com/ee/user/gitlab_com/index.html#gitlabcom-specific-rate-limits
304// https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-authenticated-users
305
306// Github 5000 requests per hour for authenticated users
307// Gitlab 2000 requests per minute for authenticated users
308// Most limiting Github 5000/60 = 83.33 requests per minute
309
310pub fn parse_ratelimit_headers(headers: Option<&Headers>) -> Option<RateLimitHeader> {
311    let mut ratelimit_header = RateLimitHeader::default();
312
313    // process remote headers and patch the defaults accordingly
314    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        // special case handled by the caller (merge_request)
504        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}