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/";
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum LinkHeaderParseError {
#[error("invalid header")]
InvalidHeader {
#[from]
source: reqwest::header::ToStrError,
},
#[deprecated(
since = "0.1902.1",
note = "parsing is now delegated to the `http-link` crate; see `ParseFailed`"
)]
#[error("missing brackets around url")]
NoBrackets,
#[deprecated(
since = "0.1902.1",
note = "parsing is now delegated to the `http-link` crate; see `ParseFailed`"
)]
#[error("missing parameter value")]
MissingParamValue,
#[error("failed to parse link header: {source}")]
ParseFailed {
#[from]
source: http_link::ParseLinkError,
},
#[error("expected an absolute link target, but got a relative URL: `{target}`")]
RelativeTarget {
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();
let links_headers = headers.get_all("Links").iter();
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;
}
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)?;
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() {
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() {
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:?}",
);
}
}