#![allow(clippy::mismatching_type_param_order)]
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use futures_util::Stream;
use serde::de::DeserializeOwned;
use crate::error::{HonchoError, Result};
use crate::http::client::HttpClient;
type PageFetcher<TRaw> = Arc<
dyn Fn(u64) -> Pin<Box<dyn Future<Output = Result<PageResponse<TRaw>>> + Send>> + Send + Sync,
>;
const MAX_PAGE_SIZE: u64 = 100;
const REVERSE_TRUE: &str = "true";
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PageResponse<T> {
pub items: Vec<T>,
pub total: u64,
pub page: u64,
pub size: u64,
pub pages: u64,
}
impl<T> PageResponse<T> {
#[must_use]
pub fn new(items: Vec<T>, total: u64, page: u64, size: u64, pages: u64) -> Self {
Self {
items,
total,
page,
size,
pages,
}
}
}
impl<T> Default for PageResponse<T> {
fn default() -> Self {
Self {
items: Vec::new(),
total: 0,
page: 1,
size: 0,
pages: 0,
}
}
}
impl<T: 'static> PageResponse<T> {
#[must_use]
pub fn with_fetcher<F, Fut>(self, fetcher: F) -> Page<T, T>
where
F: Fn(u64) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<PageResponse<T>>> + Send + 'static,
{
Page {
inner: Arc::new(PageInner {
items: self.items,
total: self.total,
page: self.page,
size: self.size,
pages: self.pages,
next_fetcher: Some(Arc::new(move |pn| Box::pin(fetcher(pn)))),
transform: Arc::new(std::convert::identity),
}),
}
}
}
pub struct Page<TRaw, TOut = TRaw> {
inner: Arc<PageInner<TRaw, TOut>>,
}
struct PageInner<TRaw, TOut> {
items: Vec<TRaw>,
total: u64,
page: u64,
size: u64,
pages: u64,
next_fetcher: Option<PageFetcher<TRaw>>,
transform: Arc<dyn Fn(TRaw) -> TOut + Send + Sync>,
}
impl<TRaw: 'static, TOut: 'static> Page<TRaw, TOut> {
#[must_use]
pub fn raw_items(&self) -> &[TRaw] {
&self.inner.items
}
#[must_use]
pub fn items(&self) -> Vec<TOut>
where
TRaw: Clone,
{
self.inner
.items
.iter()
.cloned()
.map(|v| (self.inner.transform)(v))
.collect()
}
#[must_use]
pub fn total(&self) -> u64 {
self.inner.total
}
#[must_use]
pub fn page(&self) -> u64 {
self.inner.page
}
#[must_use]
pub fn size(&self) -> u64 {
self.inner.size
}
#[must_use]
pub fn pages(&self) -> u64 {
self.inner.pages
}
#[must_use]
pub fn has_next(&self) -> bool {
self.inner.page < self.inner.pages
}
pub async fn next_page(&self) -> Result<Option<Self>> {
if !self.has_next() {
return Ok(None);
}
let fetcher = match self.inner.next_fetcher.as_ref() {
Some(f) => Arc::clone(f),
None => return Ok(None),
};
let Some(next_num) = self.inner.page.checked_add(1) else {
return Ok(None);
};
let transform = Arc::clone(&self.inner.transform);
let next_fetcher = self.inner.next_fetcher.clone();
let resp = fetcher(next_num).await?;
Ok(Some(Self {
inner: Arc::new(PageInner {
items: resp.items,
total: resp.total,
page: resp.page,
size: resp.size,
pages: resp.pages,
next_fetcher,
transform,
}),
}))
}
#[must_use]
pub fn with_fetcher<F, Fut>(self, fetcher: F) -> Self
where
F: Fn(u64) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<PageResponse<TRaw>>> + Send + 'static,
TRaw: Clone,
{
let next_fetcher: PageFetcher<TRaw> = Arc::new(move |pn| Box::pin(fetcher(pn)));
let inner = match Arc::try_unwrap(self.inner) {
Ok(inner) => PageInner {
items: inner.items,
total: inner.total,
page: inner.page,
size: inner.size,
pages: inner.pages,
next_fetcher: Some(next_fetcher),
transform: inner.transform,
},
Err(arc) => PageInner {
items: arc.items.clone(),
total: arc.total,
page: arc.page,
size: arc.size,
pages: arc.pages,
next_fetcher: Some(next_fetcher),
transform: Arc::clone(&arc.transform),
},
};
Self {
inner: Arc::new(inner),
}
}
pub fn map<TNewOut>(
self,
f: impl Fn(TOut) -> TNewOut + Send + Sync + 'static,
) -> Page<TRaw, TNewOut>
where
TRaw: Clone,
{
let inner = match Arc::try_unwrap(self.inner) {
Ok(inner) => {
let prev = inner.transform;
PageInner {
items: inner.items,
total: inner.total,
page: inner.page,
size: inner.size,
pages: inner.pages,
next_fetcher: inner.next_fetcher,
transform: Arc::new(move |raw| f(prev(raw))),
}
}
Err(arc) => {
let prev = Arc::clone(&arc.transform);
PageInner {
items: arc.items.clone(),
total: arc.total,
page: arc.page,
size: arc.size,
pages: arc.pages,
next_fetcher: arc.next_fetcher.clone(),
transform: Arc::new(move |raw| f(prev(raw))),
}
}
};
Page {
inner: Arc::new(inner),
}
}
pub fn into_stream(self) -> impl Stream<Item = Result<TOut>> + Send + 'static
where
TRaw: Clone + Send + 'static,
TOut: Send + 'static,
{
let has_next = self.has_next();
let next_page_num = self.inner.page.checked_add(1);
let (items, fetcher, transform) = match Arc::try_unwrap(self.inner) {
Ok(inner) => (inner.items, inner.next_fetcher, inner.transform),
Err(arc) => (
arc.items.clone(),
arc.next_fetcher.clone(),
Arc::clone(&arc.transform),
),
};
async_stream::try_stream! {
for item in items {
yield transform(item);
}
if let Some(fetcher) = fetcher
&& has_next
&& let Some(start) = next_page_num
{
let mut current_page = start;
loop {
let resp = (fetcher)(current_page).await?;
let is_last = resp.page >= resp.pages;
for item in resp.items {
yield transform(item);
}
if is_last {
break;
}
let Some(next) = resp.page.checked_add(1) else {
break;
};
if next <= current_page {
break;
}
current_page = next;
}
}
}
}
}
impl<TRaw: 'static> Page<TRaw, TRaw> {
#[must_use]
pub fn new(items: Vec<TRaw>, total: u64, page: u64, size: u64, pages: u64) -> Self {
Self {
inner: Arc::new(PageInner {
items,
total,
page,
size,
pages,
next_fetcher: None,
transform: Arc::new(std::convert::identity),
}),
}
}
#[must_use]
pub fn from_page_response(resp: PageResponse<TRaw>) -> Self {
Self::new(resp.items, resp.total, resp.page, resp.size, resp.pages)
}
#[must_use]
pub fn items_ref(&self) -> &[TRaw] {
&self.inner.items
}
#[must_use]
pub fn into_items(self) -> Vec<TRaw>
where
TRaw: Clone,
{
match Arc::try_unwrap(self.inner) {
Ok(inner) => inner.items,
Err(arc) => arc.items.clone(),
}
}
}
impl<TRaw: 'static> Default for Page<TRaw, TRaw> {
fn default() -> Self {
Self {
inner: Arc::new(PageInner {
items: Vec::new(),
total: 0,
page: 1,
size: 0,
pages: 0,
next_fetcher: None,
transform: Arc::new(std::convert::identity),
}),
}
}
}
impl<TRaw, TOut> Clone for Page<TRaw, TOut> {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}
impl<TRaw: fmt::Debug, TOut> fmt::Debug for Page<TRaw, TOut> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Page")
.field("items", &self.inner.items)
.field("total", &self.inner.total)
.field("page", &self.inner.page)
.field("size", &self.inner.size)
.field("pages", &self.inner.pages)
.finish_non_exhaustive()
}
}
impl<TRaw: PartialEq> PartialEq for Page<TRaw, TRaw> {
fn eq(&self, other: &Self) -> bool {
self.inner.items == other.inner.items
&& self.inner.total == other.inner.total
&& self.inner.page == other.inner.page
&& self.inner.size == other.inner.size
&& self.inner.pages == other.inner.pages
}
}
impl<TRaw: Eq> Eq for Page<TRaw, TRaw> {}
impl<TRaw: serde::Serialize> serde::Serialize for Page<TRaw, TRaw> {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut s = serializer.serialize_struct("Page", 5)?;
s.serialize_field("items", &self.inner.items)?;
s.serialize_field("total", &self.inner.total)?;
s.serialize_field("page", &self.inner.page)?;
s.serialize_field("size", &self.inner.size)?;
s.serialize_field("pages", &self.inner.pages)?;
s.end()
}
}
impl<'de, TRaw: serde::Deserialize<'de> + 'static> serde::Deserialize<'de> for Page<TRaw, TRaw> {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
let resp = PageResponse::<TRaw>::deserialize(deserializer)?;
Ok(Self::from_page_response(resp))
}
}
fn build_page_query<'a>(page: &'a str, size: &'a str, reverse: bool) -> Vec<(&'a str, &'a str)> {
let mut query = vec![("page", page), ("size", size)];
if reverse {
query.push(("reverse", REVERSE_TRUE));
}
query
}
pub(crate) async fn paginate_post<T>(
http: &HttpClient,
route: &str,
body: Option<&serde_json::Value>,
page: u64,
size: u64,
reverse: bool,
) -> Result<Page<T>>
where
T: DeserializeOwned + Clone + Send + 'static,
{
validate_pagination(page, size)?;
let size_str = size.to_string();
let resp: PageResponse<T> = {
let page_str = page.to_string();
let query = build_page_query(&page_str, &size_str, reverse);
http.post(route, body, &query).await?
};
let http_clone = http.clone();
let route_arc: Arc<str> = Arc::from(route);
let body_arc: Option<Arc<serde_json::Value>> = body.map(|b| Arc::new(b.clone()));
Ok(resp.with_fetcher(move |page_num| {
let http = http_clone.clone();
let route = Arc::clone(&route_arc);
let body = body_arc.clone();
let size_str = size_str.clone();
Box::pin(async move {
let page_str = page_num.to_string();
let query = build_page_query(&page_str, &size_str, reverse);
let resp: PageResponse<T> = http.post(&route, body.as_deref(), &query).await?;
Ok(resp)
})
}))
}
pub(crate) fn validate_pagination(page: u64, size: u64) -> Result<()> {
if page == 0 {
return Err(HonchoError::Validation(
"page must be greater than or equal to 1".into(),
));
}
if !(1..=MAX_PAGE_SIZE).contains(&size) {
return Err(HonchoError::Validation(
"size must be between 1 and 100".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::{Page, paginate_post};
use crate::error::HonchoError;
use crate::http::client::HttpClient;
use crate::types::peer::Peer;
fn peer_json(id: &str) -> serde_json::Value {
serde_json::json!({
"id": id,
"workspace_id": "ws1",
"created_at": "2025-01-15T10:30:00Z",
"metadata": {},
"configuration": {}
})
}
fn page_json(
item_ids: &[&str],
total: u64,
page: u64,
size: u64,
pages: u64,
) -> serde_json::Value {
serde_json::json!({
"items": item_ids.iter().map(|id| peer_json(id)).collect::<Vec<_>>(),
"total": total,
"page": page,
"size": size,
"pages": pages
})
}
#[tokio::test]
async fn paginate_post_returns_first_page() {
use wiremock::matchers::{body_json, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let http = HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.max_retries(0)
.build(),
)
.unwrap();
let page1_body = page_json(&["alice", "bob"], 5, 1, 2, 3);
let request_body = serde_json::json!({"filter": true});
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/peers/list"))
.and(query_param("page", "1"))
.and(query_param("size", "2"))
.and(body_json(&request_body))
.respond_with(ResponseTemplate::new(200).set_body_json(page1_body))
.mount(&server)
.await;
let page: Page<Peer> = paginate_post(
&http,
"/v3/workspaces/ws1/peers/list",
Some(&request_body),
1,
2,
false,
)
.await
.unwrap();
assert_eq!(page.items().len(), 2);
assert_eq!(page.total(), 5);
assert_eq!(page.page(), 1);
assert_eq!(page.pages(), 3);
assert!(page.has_next());
}
#[tokio::test]
async fn paginate_post_next_page_auto_fetches() {
use wiremock::matchers::{body_json, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let http = HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.max_retries(0)
.build(),
)
.unwrap();
let page1_body = page_json(&["alice", "bob"], 5, 1, 2, 3);
let page2_body = page_json(&["carol", "dave"], 5, 2, 2, 3);
let request_body = serde_json::json!({});
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/peers/list"))
.and(query_param("page", "1"))
.respond_with(ResponseTemplate::new(200).set_body_json(page1_body))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/peers/list"))
.and(query_param("page", "2"))
.and(body_json(&request_body))
.respond_with(ResponseTemplate::new(200).set_body_json(page2_body))
.mount(&server)
.await;
let page1: Page<Peer> = paginate_post(
&http,
"/v3/workspaces/ws1/peers/list",
Some(&request_body),
1,
2,
false,
)
.await
.unwrap();
assert_eq!(page1.items()[0].id, "alice");
let page2 = page1
.next_page()
.await
.unwrap()
.expect("page 2 should exist");
assert_eq!(page2.items().len(), 2);
assert_eq!(page2.page(), 2);
assert_eq!(page2.items()[0].id, "carol");
assert!(page2.has_next());
}
#[tokio::test]
async fn paginate_post_with_reverse_param() {
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let http = HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.max_retries(0)
.build(),
)
.unwrap();
let page1_body = page_json(&["zoe"], 1, 1, 2, 1);
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/peers/list"))
.and(query_param("page", "1"))
.and(query_param("size", "2"))
.and(query_param("reverse", "true"))
.respond_with(ResponseTemplate::new(200).set_body_json(page1_body))
.mount(&server)
.await;
let page: Page<Peer> =
paginate_post(&http, "/v3/workspaces/ws1/peers/list", None, 1, 2, true)
.await
.unwrap();
assert_eq!(page.items()[0].id, "zoe");
assert!(!page.has_next());
}
#[tokio::test]
async fn paginate_post_sends_page_one_size_one_query() {
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let http = HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.max_retries(0)
.build(),
)
.unwrap();
let page_body = page_json(&["alice"], 1, 1, 1, 1);
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/peers/list"))
.and(query_param("page", "1"))
.and(query_param("size", "1"))
.respond_with(ResponseTemplate::new(200).set_body_json(page_body))
.expect(1)
.mount(&server)
.await;
let page: Page<Peer> =
paginate_post(&http, "/v3/workspaces/ws1/peers/list", None, 1, 1, false)
.await
.unwrap();
assert_eq!(page.items()[0].id, "alice");
assert_eq!(page.page(), 1);
assert_eq!(page.size(), 1);
}
#[tokio::test]
async fn paginate_post_rejects_invalid_page_and_size_before_request() {
use wiremock::MockServer;
let server = MockServer::start().await;
let http = HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.max_retries(0)
.build(),
)
.unwrap();
for (page, size) in [(0, 50), (1, 0), (1, 101)] {
let err = paginate_post::<Peer>(
&http,
"/v3/workspaces/ws1/peers/list",
None,
page,
size,
false,
)
.await
.unwrap_err();
assert!(matches!(err, HonchoError::Validation(_)));
}
let requests = server.received_requests().await.unwrap();
assert!(
requests.is_empty(),
"invalid pagination should not send requests"
);
}
#[tokio::test]
async fn paginate_post_allows_large_page_and_size_100() {
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let http = HttpClient::from_params(
HttpClient::builder()
.base_url(server.uri())
.max_retries(0)
.build(),
)
.unwrap();
let page_body = page_json(&[], 0, 9999, 100, 0);
Mock::given(method("POST"))
.and(path("/v3/workspaces/ws1/peers/list"))
.and(query_param("page", "9999"))
.and(query_param("size", "100"))
.respond_with(ResponseTemplate::new(200).set_body_json(page_body))
.expect(1)
.mount(&server)
.await;
let page: Page<Peer> = paginate_post(
&http,
"/v3/workspaces/ws1/peers/list",
None,
9999,
100,
false,
)
.await
.unwrap();
assert_eq!(page.page(), 9999);
assert_eq!(page.size(), 100);
}
}