use std::marker::PhantomData;
use std::pin::Pin;
use futures_core::Stream;
use reqwest::{Method, header};
use serde::de::DeserializeOwned;
use url::Url;
use crate::client::{Client, Res};
use crate::error::{Error, Result};
use crate::ratelimit::ScopeSet;
pub type ItemStream<T> = Pin<Box<dyn Stream<Item = Result<T>> + Send>>;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Cursor(String);
impl Cursor {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<String> for Cursor {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&str> for Cursor {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
#[derive(Debug, Clone)]
pub struct Page<T> {
pub items: Vec<T>,
pub first: Option<Cursor>,
pub prev: Option<Cursor>,
pub next: Option<Cursor>,
}
impl<T> Page<T> {
pub fn has_next(&self) -> bool {
self.next.is_some()
}
}
pub struct ListRequest<T> {
client: Client,
url: Url,
scopes: ScopeSet,
cursor: Option<Cursor>,
marker: PhantomData<fn() -> T>,
}
impl<T> Clone for ListRequest<T> {
fn clone(&self) -> Self {
Self {
client: self.client.clone(),
url: self.url.clone(),
scopes: self.scopes.clone(),
cursor: self.cursor.clone(),
marker: PhantomData,
}
}
}
impl<T> std::fmt::Debug for ListRequest<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ListRequest")
.field("url", &self.url.as_str())
.field("cursor", &self.cursor)
.finish_non_exhaustive()
}
}
impl<T> ListRequest<T> {
pub(crate) fn new(client: Client, url: Url, scopes: ScopeSet) -> Self {
Self {
client,
url,
scopes,
cursor: None,
marker: PhantomData,
}
}
pub fn cursor(mut self, cursor: impl Into<Cursor>) -> Self {
self.cursor = Some(cursor.into());
self
}
pub fn filter(mut self, key: &str, value: &str) -> Self {
self.url.query_pairs_mut().append_pair(key, value);
self
}
pub(crate) fn with_filter(self, key: &str, value: &str) -> Self {
self.filter(key, value)
}
}
impl<T: DeserializeOwned> ListRequest<T> {
pub async fn send(self) -> Result<Page<T>> {
let mut req = self
.client
.request(Method::GET, self.url, self.scopes.clone());
req.url_mut()
.query_pairs_mut()
.append_pair("cursor", self.cursor.as_ref().map_or("", Cursor::as_str));
let res = self.client.send(req).await?;
let links = parse_link_header(&res)?;
Ok(Page {
items: res.json()?,
first: links.first,
prev: links.prev,
next: links.next,
})
}
pub async fn all(self) -> Result<Vec<T>> {
let mut out = Vec::new();
let mut request = self;
loop {
let next = request.clone();
let current = request.cursor.clone();
let page = request.send().await?;
out.extend(page.items);
match page.next {
Some(cursor) if Some(&cursor) == current.as_ref() => return Ok(out),
Some(cursor) => request = next.cursor(cursor),
None => return Ok(out),
}
}
}
}
impl<T: DeserializeOwned + Send + 'static> ListRequest<T> {
pub fn stream(self) -> ItemStream<T> {
Box::pin(async_stream::try_stream! {
let mut request = Some(self);
while let Some(current) = request.take() {
let resume = current.clone();
let previous = current.cursor.clone();
let page = current.send().await?;
for item in page.items {
yield item;
}
if let Some(cursor) = page.next {
if Some(&cursor) != previous.as_ref() {
request = Some(resume.cursor(cursor));
}
}
}
})
}
}
#[derive(Debug, Default, PartialEq, Eq)]
struct Links {
first: Option<Cursor>,
prev: Option<Cursor>,
next: Option<Cursor>,
}
fn parse_link_header(res: &Res) -> Result<Links> {
let mut links = Links::default();
for value in res.headers.get_all(header::LINK) {
let raw = value
.to_str()
.map_err(|_| Error::MalformedLink("header is not valid text".to_owned()))?;
for (url, rel) in split_links(raw) {
let base = Url::parse(res.url.as_str()).ok();
let parsed = match base
.as_ref()
.map_or_else(|| Url::parse(url), |b| b.join(url))
{
Ok(parsed) => parsed,
Err(_) => return Err(Error::MalformedLink(url.to_owned())),
};
let cursor = parsed
.query_pairs()
.find(|(key, _)| key == "cursor")
.map(|(_, value)| Cursor(value.into_owned()));
let Some(cursor) = cursor else { continue };
match rel {
"first" => links.first = Some(cursor),
"prev" | "previous" => links.prev = Some(cursor),
"next" => links.next = Some(cursor),
_ => {}
}
}
}
Ok(links)
}
fn split_links(raw: &str) -> Vec<(&str, &str)> {
let mut out = Vec::new();
let mut rest = raw;
while let Some(open) = rest.find('<') {
let after_open = &rest[open + 1..];
let Some(close) = after_open.find('>') else {
break;
};
let url = &after_open[..close];
let mut params = &after_open[close + 1..];
match params.find('<') {
Some(next_open) => {
let boundary = params[..next_open].rfind(',').unwrap_or(next_open);
rest = ¶ms[boundary..];
params = ¶ms[..boundary];
}
None => rest = "",
}
for param in params.split(';') {
let Some((key, value)) = param.split_once('=') else {
continue;
};
if key.trim().eq_ignore_ascii_case("rel") {
out.push((url, value.trim().trim_matches('"')));
}
}
}
out
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::*;
use reqwest::StatusCode;
use reqwest::header::HeaderMap;
fn response(link: &str) -> Res {
let mut headers = HeaderMap::new();
if !link.is_empty() {
headers.insert(
header::LINK,
link.parse().expect("test header value is valid"),
);
}
Res {
status: StatusCode::OK,
headers,
body: b"[]".to_vec(),
method: Method::GET,
path: "/api/v1/domains/".to_owned(),
url: Url::parse("https://desec.io/api/v1/domains/").expect("valid"),
}
}
#[test]
fn extracts_next_and_prev_cursors() {
let res = response(
r#"<https://desec.io/api/v1/domains/?cursor=b2Zmc2V0PTUwMA%3D%3D>; rel="next", <https://desec.io/api/v1/domains/?cursor=>; rel="first""#,
);
let links = parse_link_header(&res).expect("well-formed");
assert_eq!(
links.next.as_ref().map(Cursor::as_str),
Some("b2Zmc2V0PTUwMA==")
);
assert_eq!(links.first.as_ref().map(Cursor::as_str), Some(""));
assert_eq!(links.prev, None);
}
#[test]
fn accepts_both_prev_spellings() {
for rel in ["prev", "previous"] {
let res = response(&format!(
r#"<https://desec.io/api/v1/domains/?cursor=abc>; rel="{rel}""#
));
let links = parse_link_header(&res).expect("well-formed");
assert_eq!(
links.prev.as_ref().map(Cursor::as_str),
Some("abc"),
"{rel}"
);
}
}
#[test]
fn a_comma_inside_a_url_does_not_split_the_header() {
let res =
response(r#"<https://desec.io/api/v1/domains/?cursor=a%2Cb&subname=x>; rel="next""#);
let links = parse_link_header(&res).expect("well-formed");
assert_eq!(links.next.as_ref().map(Cursor::as_str), Some("a,b"));
}
#[test]
fn no_link_header_means_a_single_page() {
let links = parse_link_header(&response("")).expect("well-formed");
assert_eq!(links, Links::default());
}
#[test]
fn links_without_a_cursor_are_ignored() {
let res = response(r#"<https://desec.io/api/v1/domains/>; rel="next""#);
let links = parse_link_header(&res).expect("well-formed");
assert_eq!(links.next, None);
}
#[test]
fn preserves_filters_when_resuming() {
let client = Client::builder()
.base_url("https://desec.example/api/v1")
.build()
.expect("valid");
let url = client.url(&["domains", "example.com", "rrsets"]);
let request: ListRequest<()> = ListRequest::new(client, url, ScopeSet::default())
.filter("type", "A")
.cursor("abc");
assert_eq!(request.url.query(), Some("type=A"));
assert_eq!(request.cursor.as_ref().map(Cursor::as_str), Some("abc"));
}
}