gitlab 0.1904.0

Gitlab API client.
Documentation
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.

use http::HeaderMap;
use thiserror::Error;
use url::Url;

use crate::api::PaginationError;

static SENTINEL_HOST: &str = "rust-gitlab.example.invalid";
static SENTINEL_BASE_URL: &str = "https://rust-gitlab.example.invalid/";

/// An error which can occur when parsing a link header.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum LinkHeaderParseError {
    /// An invalid HTTP header was found.
    #[error("invalid header")]
    InvalidHeader {
        /// The source of the error.
        #[from]
        source: reqwest::header::ToStrError,
    },
    /// The `url` for a `Link` header was missing `<>` brackets.
    #[deprecated(
        since = "0.1902.1",
        note = "parsing is now delegated to the `http-link` crate; see `ParseFailed`"
    )]
    #[error("missing brackets around url")]
    NoBrackets,
    /// A parameter for a `Link` header was missing a value.
    #[deprecated(
        since = "0.1902.1",
        note = "parsing is now delegated to the `http-link` crate; see `ParseFailed`"
    )]
    #[error("missing parameter value")]
    MissingParamValue,
    /// Link header parsing failed.
    #[error("failed to parse link header: {source}")]
    ParseFailed {
        /// The source of the error.
        #[from]
        source: http_link::ParseLinkError,
    },
    /// A link target was resolved against the sentinel base URL, meaning
    /// the server returned a relative URL where an absolute one was expected.
    #[error("expected an absolute link target, but got a relative URL: `{target}`")]
    RelativeTarget {
        /// The link target that resolved against the sentinel base.
        target: String,
    },
}

impl LinkHeaderParseError {
    fn invalid_header(source: reqwest::header::ToStrError) -> Self {
        Self::InvalidHeader {
            source,
        }
    }

    fn parse_failed(source: http_link::ParseLinkError) -> Self {
        Self::ParseFailed {
            source,
        }
    }

    fn relative_target(target: String) -> Self {
        Self::RelativeTarget {
            target,
        }
    }
}

pub(crate) fn next_page_from_headers(headers: &HeaderMap) -> Result<Option<Url>, PaginationError> {
    let link_headers = headers.get_all(reqwest::header::LINK).iter();
    // GitLab 14.0 will deprecate this header in preference for the W3C spec's `Link` header. Make
    // it less preferred to it in anticipation for this change.
    let links_headers = headers.get_all("Links").iter();

    // A single `Link` header field may itself contain several comma-separated links (RFC 8288),
    // so expand every field into its individual links before searching for the `next` relation.
    let mut parsed = Vec::new();
    for header in link_headers.chain(links_headers) {
        let value = header
            .to_str()
            .map_err(LinkHeaderParseError::invalid_header)?;

        if value.is_empty() {
            continue;
        }

        // GitLab should always return absolute URLs. However, a base URL is
        // required by the http-link crate's API. We pass a sentinel value and
        // detect it leaking through, which signals the server sent a relative URL.
        let base_url = Url::parse(SENTINEL_BASE_URL).expect("valid base URL");
        let links = http_link::parse_link_header(value, &base_url)
            .map_err(LinkHeaderParseError::parse_failed)?;

        // Check for the invalid sentinel host
        for link in &links {
            if link.target.host_str() == Some(SENTINEL_HOST) {
                return Err(LinkHeaderParseError::relative_target(link.target.to_string()).into());
            }
        }

        parsed.extend(links);
    }

    Ok(parsed
        .into_iter()
        .find(|link| link.rel == "next")
        .map(|link| link.target))
}

#[cfg(test)]
mod tests {
    use http::{HeaderMap, HeaderValue};

    use crate::api::{LinkHeaderParseError, PaginationError};

    #[test]
    fn test_link_header_next_matching() {
        let headers = HeaderMap::from_iter([
            (
                http::header::LINK,
                HeaderValue::from_static("<https://example.com/prev>; rel=\"prev\""),
            ),
            (
                http::header::LINK,
                HeaderValue::from_static("<https://example.com/temporal>; when=\"next\""),
            ),
            (
                http::header::LINK,
                HeaderValue::from_static("<https://example.com/next>; rel=\"next\""),
            ),
        ]);

        if let Some(url) = super::next_page_from_headers(&headers).unwrap() {
            assert_eq!(url.as_str(), "https://example.com/next");
        } else {
            panic!("failed to extract url from headers");
        }
    }

    #[test]
    fn test_next_page_comma_joined_next_first() {
        // Regression: the pre-fix parser failed whenever `next` was not the first link in the field.
        let headers = HeaderMap::from_iter([(
            http::header::LINK,
            HeaderValue::from_static(
                "<https://example.com/next>; rel=\"next\", \
                 <https://example.com/first>; rel=\"first\", \
                 <https://example.com/last>; rel=\"last\"",
            ),
        )]);

        let url = super::next_page_from_headers(&headers)
            .unwrap()
            .expect("expected a next page");
        assert_eq!(url.as_str(), "https://example.com/next");
    }

    #[test]
    fn test_next_page_comma_joined_next_not_first() {
        // Regression: the pre-fix parser failed whenever `next` was not the first link in the field.
        let headers = HeaderMap::from_iter([(
            http::header::LINK,
            HeaderValue::from_static(
                "<https://example.com/first>; rel=\"first\", \
                 <https://example.com/next>; rel=\"next\", \
                 <https://example.com/last>; rel=\"last\"",
            ),
        )]);

        let url = super::next_page_from_headers(&headers)
            .unwrap()
            .expect("expected a next page");
        assert_eq!(url.as_str(), "https://example.com/next");
    }

    #[test]
    fn test_next_page_comma_joined_no_next() {
        let headers = HeaderMap::from_iter([(
            http::header::LINK,
            HeaderValue::from_static(
                "<https://example.com/first>; rel=\"first\", \
                 <https://example.com/last>; rel=\"last\"",
            ),
        )]);

        assert!(super::next_page_from_headers(&headers).unwrap().is_none());
    }

    #[test]
    fn test_next_page_comma_in_uri() {
        let headers = HeaderMap::from_iter([(
            http::header::LINK,
            HeaderValue::from_static(
                "<https://example.com/items?ids=1,2,3&cursor=abc>; rel=\"next\", \
                 <https://example.com/items?ids=1,2,3&page=1>; rel=\"first\"",
            ),
        )]);

        let url = super::next_page_from_headers(&headers)
            .unwrap()
            .expect("expected a next page");
        assert_eq!(
            url.as_str(),
            "https://example.com/items?ids=1,2,3&cursor=abc",
        );
    }

    #[test]
    fn test_next_page_empty_field_is_no_next() {
        let headers = HeaderMap::from_iter([(http::header::LINK, HeaderValue::from_static(""))]);

        assert!(super::next_page_from_headers(&headers).unwrap().is_none());
    }

    #[test]
    fn test_next_page_relative_url_is_error() {
        let headers = HeaderMap::from_iter([(
            http::header::LINK,
            HeaderValue::from_static("</items?ids=1,2,3&cursor=abc>; rel=\"next\""),
        )]);

        let err = super::next_page_from_headers(&headers).unwrap_err();
        assert!(
            matches!(
                err,
                PaginationError::LinkHeader {
                    source: LinkHeaderParseError::RelativeTarget { .. }
                },
            ),
            "expected RelativeTarget, got: {err:?}",
        );
    }
}