use std::ops::Deref;
use futures_util::Stream;
use futures_util::stream::{self, StreamExt};
use serde::de::DeserializeOwned;
use serde_json::Value;
use url::Url;
use crate::client::{Client, Response};
use crate::error::Error;
use crate::http::Method;
use crate::observability::OperationInfo;
use crate::operation::{Operation, RetryPolicy};
use crate::security::is_same_origin;
#[derive(Debug, Clone)]
pub struct Page<T> {
value: T,
next_url: Option<Url>,
next_cursor: Option<String>,
total_count: Option<u64>,
info: OperationInfo,
retry: Option<RetryPolicy>,
}
#[derive(Debug, Clone)]
struct Cursor {
next_url: Option<Url>,
info: OperationInfo,
retry: Option<RetryPolicy>,
}
impl<T> Page<T> {
pub(crate) fn new(
value: T,
response: &Response,
info: OperationInfo,
retry: Option<RetryPolicy>,
) -> Page<T> {
let next_url = response
.headers
.get("link")
.and_then(|value| value.to_str().ok())
.and_then(next_link)
.and_then(|target| response.url.join(&target).ok());
let next_cursor = next_url.as_ref().and_then(|url| {
url.query_pairs()
.find(|(name, _)| name == "page")
.map(|(_, value)| value.into_owned())
});
let total_count = response
.headers
.get("x-total-count")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.trim().parse().ok());
Page {
value,
next_url,
next_cursor,
total_count,
info,
retry,
}
}
fn cursor(&self) -> Cursor {
Cursor {
next_url: self.next_url.clone(),
info: self.info.clone(),
retry: self.retry.clone(),
}
}
pub fn into_inner(self) -> T {
self.value
}
pub fn value(&self) -> &T {
&self.value
}
pub fn next_page(&self) -> Option<&str> {
self.next_cursor.as_deref()
}
pub fn next_url(&self) -> Option<&Url> {
self.next_url.as_ref()
}
pub fn has_next(&self) -> bool {
self.next_url.is_some()
}
pub fn total_count(&self) -> Option<u64> {
self.total_count
}
pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Page<U> {
Page {
value: f(self.value),
next_url: self.next_url,
next_cursor: self.next_cursor,
total_count: self.total_count,
info: self.info,
retry: self.retry,
}
}
}
impl<T> Deref for Page<T> {
type Target = T;
fn deref(&self) -> &T {
&self.value
}
}
impl Client {
pub async fn next_page<T: DeserializeOwned>(
&self,
page: &Page<T>,
) -> Result<Option<Page<T>>, Error> {
self.page_after(&page.cursor()).await
}
async fn page_after<T: DeserializeOwned>(
&self,
cursor: &Cursor,
) -> Result<Option<Page<T>>, Error> {
match &cursor.next_url {
None => Ok(None),
Some(next) if !is_same_origin(next, self.base_url()) => Err(Error::usage(format!(
"pagination Link header points to a different origin: {next}"
))),
Some(next) => {
let mut operation = Operation::at(Method::GET, next.clone());
operation.info(cursor.info.clone());
if let Some(retry) = &cursor.retry {
operation.retry(retry.clone());
}
self.send_page(operation).await.map(Some)
}
}
}
pub async fn each_page<T: DeserializeOwned>(
&self,
first: Page<T>,
mut visit: impl FnMut(&Page<T>) -> bool,
) -> Result<(), Error> {
let mut page = first;
let mut count = 1;
while visit(&page) && count < self.max_pages() {
match self.next_page(&page).await? {
Some(next) => page = next,
None => break,
}
count += 1;
}
Ok(())
}
pub fn pages<'a, T: DeserializeOwned + 'a>(
&'a self,
first: Page<T>,
) -> impl Stream<Item = Result<Page<T>, Error>> + 'a {
let max_pages = self.max_pages();
stream::try_unfold(
(Some(first), None::<Cursor>, 0usize),
move |(pending, cursor, read)| async move {
let page = match (pending, cursor) {
(Some(page), _) => page,
(None, Some(cursor)) if read < max_pages => {
match self.page_after(&cursor).await? {
Some(page) => page,
None => return Ok(None),
}
}
(None, _) => return Ok(None),
};
let cursor = page.cursor();
Ok(Some((page, (None, Some(cursor), read + 1))))
},
)
}
pub fn items<'a, T: DeserializeOwned + 'a>(
&'a self,
first: Page<Vec<T>>,
) -> impl Stream<Item = Result<T, Error>> + 'a {
self.pages(first).flat_map(|page| match page {
Ok(page) => stream::iter(page.into_inner().into_iter().map(Ok)).left_stream(),
Err(error) => stream::once(async move { Err(error) }).right_stream(),
})
}
pub async fn get_all(&self, path: &str) -> Result<Vec<Value>, Error> {
self.get_all_with_limit(path, 0).await
}
pub async fn get_all_with_limit(&self, path: &str, limit: usize) -> Result<Vec<Value>, Error> {
let operation = self.raw(Method::GET, path)?;
self.collect_all(operation, limit).await
}
pub(crate) async fn collect_all(
&self,
mut operation: Operation,
limit: usize,
) -> Result<Vec<Value>, Error> {
let started_at = self.url_for(&operation)?;
let retry = operation.retry.clone();
let mut collected: Vec<Value> = Vec::new();
let mut pages = 0;
loop {
let response = self.execute(operation).await?;
collected.extend(response.json::<Vec<Value>>()?);
pages += 1;
if limit > 0 && collected.len() >= limit {
collected.truncate(limit);
break;
}
match Client::next_page_url(&response, &started_at)? {
Some(next) if pages < self.max_pages() => {
operation = Operation::at(Method::GET, next);
if let Some(retry) = &retry {
operation.retry(retry.clone());
}
}
Some(_) => {
crate::trace::warn(&format!("pagination capped at {} pages", self.max_pages()));
break;
}
None => break,
}
}
Ok(collected)
}
fn next_page_url(response: &Response, started_at: &Url) -> Result<Option<Url>, Error> {
match response.header("link").and_then(next_link) {
None => Ok(None),
Some(target) => {
let next = response.url.join(&target)?;
if is_same_origin(&next, started_at) {
Ok(Some(next))
} else {
Err(Error::usage(format!(
"pagination Link header points to a different origin: {next}"
)))
}
}
}
}
}
pub fn next_link(header: &str) -> Option<String> {
let mut remaining = header;
while let Some(start) = remaining.find('<') {
let after_start = &remaining[start + 1..];
let end = after_start.find('>')?;
let target = &after_start[..end];
let rest = &after_start[end + 1..];
let params_end = rest.find('<').unwrap_or(rest.len());
let params = rest[..params_end].trim().trim_end_matches(',');
if link_is_next(params) {
return Some(target.to_string());
}
remaining = &rest[params_end..];
}
None
}
fn link_is_next(params: &str) -> bool {
params.split(';').any(|param| {
let mut parts = param.splitn(2, '=');
let name = parts.next().unwrap_or_default().trim();
let value = parts.next().unwrap_or_default().trim().trim_matches('"');
name.eq_ignore_ascii_case("rel")
&& value
.split_whitespace()
.any(|rel| rel.eq_ignore_ascii_case("next"))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_the_next_link_among_others() {
let header = r#"<https://fizzy.do/999/boards.json?page=1>; rel="prev", <https://fizzy.do/999/boards.json?page=3>; rel="next""#;
assert_eq!(
next_link(header).as_deref(),
Some("https://fizzy.do/999/boards.json?page=3")
);
}
#[test]
fn finds_the_next_link_whichever_order_the_relations_come_in() {
assert_eq!(
next_link(r#"</p2>; rel="next", </p9>; rel="last""#).as_deref(),
Some("/p2")
);
assert_eq!(
next_link(r#"</p9>; rel="last",</p2>; rel="next""#).as_deref(),
Some("/p2")
);
assert_eq!(next_link(r#"</p9>; rel="last", </p1>; rel="first""#), None);
}
#[test]
fn matches_rel_sets_and_case() {
assert_eq!(
next_link(r#"</x?page=2>; REL="prev next""#).as_deref(),
Some("/x?page=2")
);
assert_eq!(next_link(r#"</x?page=2>; rel="last""#), None);
assert_eq!(next_link(""), None);
}
}