#[cfg(all(not(feature = "std"), feature = "alloc"))]
use alloc::{string::String, vec::Vec};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "validator")]
use validator::Validate;
#[cfg(any(feature = "std", feature = "alloc"))]
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
pub struct PaginatedResponse<T> {
pub items: Vec<T>,
pub total_count: u64,
pub has_more: bool,
pub limit: u64,
pub offset: u64,
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl<T> PaginatedResponse<T> {
#[must_use]
pub fn new(items: Vec<T>, total_count: u64, params: &PaginationParams) -> Self {
let limit = params.limit();
let offset = params.offset();
let has_more = offset + (items.len() as u64) < total_count;
Self {
items,
total_count,
has_more,
limit,
offset,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema, utoipa::IntoParams))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "validator", derive(Validate))]
#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
pub struct PaginationParams {
#[cfg_attr(feature = "serde", serde(default))]
#[cfg_attr(feature = "validator", validate(range(min = 1, max = 100)))]
#[cfg_attr(
feature = "proptest",
proptest(strategy = "proptest::option::of(1u64..=100u64)")
)]
pub limit: Option<u64>,
#[cfg_attr(feature = "serde", serde(default))]
pub offset: Option<u64>,
}
impl Default for PaginationParams {
fn default() -> Self {
Self {
limit: Some(20),
offset: Some(0),
}
}
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl PaginationParams {
pub fn new(limit: u64, offset: u64) -> Result<Self, crate::error::ValidationError> {
if !(1..=100).contains(&limit) {
return Err(crate::error::ValidationError {
field: "/limit".into(),
message: "must be between 1 and 100".into(),
rule: Some("range".into()),
});
}
Ok(Self {
limit: Some(limit),
offset: Some(offset),
})
}
}
impl PaginationParams {
#[must_use]
pub fn limit(&self) -> u64 {
self.limit.unwrap_or(20)
}
#[must_use]
pub fn offset(&self) -> u64 {
self.offset.unwrap_or(0)
}
}
#[cfg(any(feature = "std", feature = "alloc"))]
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
pub struct CursorPaginatedResponse<T> {
pub data: Vec<T>,
pub pagination: CursorPagination,
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl<T> CursorPaginatedResponse<T> {
#[must_use]
pub fn new(data: Vec<T>, pagination: CursorPagination) -> Self {
Self { data, pagination }
}
}
#[cfg(any(feature = "std", feature = "alloc"))]
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
pub struct CursorPagination {
pub has_more: bool,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub next_cursor: Option<String>,
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl CursorPagination {
#[must_use]
pub fn more(cursor: impl Into<String>) -> Self {
Self {
has_more: true,
next_cursor: Some(cursor.into()),
}
}
#[must_use]
pub fn last_page() -> Self {
Self {
has_more: false,
next_cursor: None,
}
}
}
#[cfg(all(feature = "serde", any(feature = "std", feature = "alloc")))]
#[allow(clippy::unnecessary_wraps)]
fn default_cursor_limit() -> Option<u64> {
Some(20)
}
#[cfg(any(feature = "std", feature = "alloc"))]
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema, utoipa::IntoParams))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "validator", derive(Validate))]
#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
pub struct CursorPaginationParams {
#[cfg_attr(feature = "serde", serde(default = "default_cursor_limit"))]
#[cfg_attr(feature = "validator", validate(range(min = 1, max = 100)))]
#[cfg_attr(
feature = "proptest",
proptest(strategy = "proptest::option::of(1u64..=100u64)")
)]
pub limit: Option<u64>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub after: Option<String>,
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl Default for CursorPaginationParams {
fn default() -> Self {
Self {
limit: Some(20),
after: None,
}
}
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl CursorPaginationParams {
pub fn new(limit: u64, after: Option<String>) -> Result<Self, crate::error::ValidationError> {
if !(1..=100).contains(&limit) {
return Err(crate::error::ValidationError {
field: "/limit".into(),
message: "must be between 1 and 100".into(),
rule: Some("range".into()),
});
}
Ok(Self {
limit: Some(limit),
after,
})
}
#[must_use]
pub fn limit(&self) -> u64 {
self.limit.unwrap_or(20)
}
#[must_use]
pub fn after(&self) -> Option<&str> {
self.after.as_deref()
}
}
#[cfg(any(feature = "std", feature = "alloc"))]
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(bound = "K: schemars::JsonSchema"))]
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct KeysetPaginationParams<K> {
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub after: Option<K>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub before: Option<K>,
#[cfg_attr(feature = "serde", serde(default = "default_keyset_limit"))]
#[cfg_attr(feature = "validator", validate(range(min = 1, max = 100)))]
pub limit: Option<u64>,
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl<K> Default for KeysetPaginationParams<K> {
fn default() -> Self {
Self {
after: None,
before: None,
limit: Some(20),
}
}
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl<K> KeysetPaginationParams<K> {
pub fn new(
limit: u64,
after: Option<K>,
before: Option<K>,
) -> Result<Self, crate::error::ValidationError> {
if !(1..=100).contains(&limit) {
return Err(crate::error::ValidationError {
field: "/limit".into(),
message: "must be between 1 and 100".into(),
rule: Some("range".into()),
});
}
Ok(Self {
after,
before,
limit: Some(limit),
})
}
#[must_use]
pub fn limit(&self) -> u64 {
self.limit.unwrap_or(20)
}
}
#[cfg(all(feature = "serde", any(feature = "std", feature = "alloc")))]
#[allow(clippy::unnecessary_wraps)]
fn default_keyset_limit() -> Option<u64> {
Some(20)
}
#[cfg(any(feature = "std", feature = "alloc"))]
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct KeysetPaginatedResponse<T> {
pub items: Vec<T>,
pub has_next: bool,
pub has_prev: bool,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub prev_cursor: Option<String>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub next_cursor: Option<String>,
}
#[cfg(any(feature = "std", feature = "alloc"))]
impl<T> KeysetPaginatedResponse<T> {
#[must_use]
pub fn new(
items: Vec<T>,
has_next: bool,
has_prev: bool,
prev_cursor: Option<String>,
next_cursor: Option<String>,
) -> Self {
Self {
items,
has_next,
has_prev,
prev_cursor,
next_cursor,
}
}
#[must_use]
pub fn first_page(items: Vec<T>, has_next: bool, next_cursor: Option<String>) -> Self {
Self::new(items, has_next, false, None, next_cursor)
}
}
#[cfg(feature = "axum")]
#[allow(clippy::result_large_err)]
mod axum_extractors {
use super::{CursorPaginationParams, PaginationParams};
use crate::error::ApiError;
use axum::extract::{FromRequestParts, Query};
use axum::http::request::Parts;
#[cfg(feature = "validator")]
use validator::Validate;
impl<S: Send + Sync> FromRequestParts<S> for PaginationParams {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let Query(params) = Query::<Self>::from_request_parts(parts, state)
.await
.map_err(|e| ApiError::bad_request(e.to_string()))?;
#[cfg(feature = "validator")]
params
.validate()
.map_err(|e| ApiError::bad_request(e.to_string()))?;
Ok(params)
}
}
impl<S: Send + Sync> FromRequestParts<S> for CursorPaginationParams {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let Query(params) = Query::<Self>::from_request_parts(parts, state)
.await
.map_err(|e| ApiError::bad_request(e.to_string()))?;
#[cfg(feature = "validator")]
params
.validate()
.map_err(|e| ApiError::bad_request(e.to_string()))?;
Ok(params)
}
}
}
#[cfg(feature = "arbitrary")]
impl<'a> arbitrary::Arbitrary<'a> for PaginationParams {
fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
use arbitrary::Arbitrary;
let limit = if bool::arbitrary(u)? {
Some(u.int_in_range(1u64..=100)?)
} else {
None
};
Ok(Self {
limit,
offset: Arbitrary::arbitrary(u)?,
})
}
}
#[cfg(all(feature = "arbitrary", any(feature = "std", feature = "alloc")))]
impl<'a> arbitrary::Arbitrary<'a> for CursorPaginationParams {
fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
use arbitrary::Arbitrary;
let limit = if bool::arbitrary(u)? {
Some(u.int_in_range(1u64..=100)?)
} else {
None
};
Ok(Self {
limit,
after: Arbitrary::arbitrary(u)?,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paginated_response_has_more_true() {
let params = PaginationParams::default();
let resp = PaginatedResponse::new(vec![1i32; 20], 25, ¶ms);
assert!(resp.has_more);
assert_eq!(resp.total_count, 25);
assert_eq!(resp.limit, 20);
assert_eq!(resp.offset, 0);
}
#[test]
fn paginated_response_has_more_false() {
let params = PaginationParams::default();
let resp = PaginatedResponse::new(vec![1i32; 5], 5, ¶ms);
assert!(!resp.has_more);
}
#[test]
fn paginated_response_exact_last_page_boundary() {
let params = PaginationParams {
limit: Some(20),
offset: Some(20),
};
let resp = PaginatedResponse::new(vec![1i32; 5], 25, ¶ms);
assert!(!resp.has_more);
}
#[test]
fn paginated_response_second_page_has_more() {
let params = PaginationParams {
limit: Some(10),
offset: Some(10),
};
let resp = PaginatedResponse::new(vec![1i32; 10], 50, ¶ms);
assert!(resp.has_more);
}
#[test]
fn pagination_params_defaults() {
let p = PaginationParams::default();
assert_eq!(p.limit(), 20);
assert_eq!(p.offset(), 0);
}
#[test]
fn pagination_params_none_falls_back_to_defaults() {
let p = PaginationParams {
limit: None,
offset: None,
};
assert_eq!(p.limit(), 20);
assert_eq!(p.offset(), 0);
}
#[test]
fn pagination_params_custom_values() {
let p = PaginationParams {
limit: Some(50),
offset: Some(100),
};
assert_eq!(p.limit(), 50);
assert_eq!(p.offset(), 100);
}
#[cfg(feature = "validator")]
#[test]
fn pagination_params_validate_min_limit() {
use validator::Validate;
let p = PaginationParams {
limit: Some(0),
offset: Some(0),
};
assert!(p.validate().is_err());
}
#[cfg(feature = "validator")]
#[test]
fn pagination_params_validate_max_limit() {
use validator::Validate;
let p = PaginationParams {
limit: Some(101),
offset: Some(0),
};
assert!(p.validate().is_err());
}
#[cfg(feature = "validator")]
#[test]
fn pagination_params_validate_boundary_values() {
use validator::Validate;
let min = PaginationParams {
limit: Some(1),
offset: Some(0),
};
assert!(min.validate().is_ok());
let max = PaginationParams {
limit: Some(100),
offset: Some(0),
};
assert!(max.validate().is_ok());
}
#[cfg(feature = "validator")]
#[test]
fn pagination_params_validate_none_limit_uses_default() {
use validator::Validate;
let p = PaginationParams {
limit: None,
offset: None,
};
assert!(p.validate().is_ok());
}
#[test]
fn pagination_params_new_valid() {
let p = PaginationParams::new(1, 0).unwrap();
assert_eq!(p.limit(), 1);
assert_eq!(p.offset(), 0);
let p = PaginationParams::new(100, 500).unwrap();
assert_eq!(p.limit(), 100);
assert_eq!(p.offset(), 500);
}
#[test]
fn pagination_params_new_limit_zero_fails() {
let err = PaginationParams::new(0, 0).unwrap_err();
assert_eq!(err.field, "/limit");
assert_eq!(err.rule.as_deref(), Some("range"));
}
#[test]
fn pagination_params_new_limit_101_fails() {
let err = PaginationParams::new(101, 0).unwrap_err();
assert_eq!(err.field, "/limit");
}
#[test]
fn cursor_pagination_params_new_valid() {
let p = CursorPaginationParams::new(1, None).unwrap();
assert_eq!(p.limit(), 1);
let p = CursorPaginationParams::new(100, Some("tok".to_string())).unwrap();
assert_eq!(p.limit(), 100);
assert_eq!(p.after(), Some("tok"));
}
#[test]
fn cursor_pagination_params_new_limit_zero_fails() {
assert!(CursorPaginationParams::new(0, None).is_err());
}
#[test]
fn cursor_pagination_params_new_limit_101_fails() {
let err = CursorPaginationParams::new(101, None).unwrap_err();
assert_eq!(err.field, "/limit");
}
#[test]
fn keyset_pagination_params_new_valid() {
let p = KeysetPaginationParams::<u64>::new(10, Some(5), None).unwrap();
assert_eq!(p.limit(), 10);
assert_eq!(p.after, Some(5));
assert!(p.before.is_none());
}
#[test]
fn keyset_pagination_params_new_limit_zero_fails() {
assert!(KeysetPaginationParams::<u64>::new(0, None, None).is_err());
}
#[test]
fn keyset_pagination_params_new_limit_101_fails() {
let err = KeysetPaginationParams::<u64>::new(101, None, None).unwrap_err();
assert_eq!(err.field, "/limit");
}
#[test]
fn cursor_pagination_more() {
let c = CursorPagination::more("abc123");
assert!(c.has_more);
assert_eq!(c.next_cursor.as_deref(), Some("abc123"));
}
#[test]
fn cursor_pagination_last() {
let c = CursorPagination::last_page();
assert!(!c.has_more);
assert!(c.next_cursor.is_none());
}
#[test]
fn cursor_paginated_response_new() {
let resp = CursorPaginatedResponse::new(vec!["a", "b"], CursorPagination::more("next"));
assert_eq!(resp.data.len(), 2);
assert!(resp.pagination.has_more);
}
#[cfg(feature = "serde")]
#[test]
fn paginated_response_serde_round_trip() {
let params = PaginationParams {
limit: Some(10),
offset: Some(20),
};
let resp = PaginatedResponse::new(vec![1i32, 2, 3], 50, ¶ms);
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["total_count"], 50);
assert_eq!(json["has_more"], true);
assert_eq!(json["limit"], 10);
assert_eq!(json["offset"], 20);
assert_eq!(json["items"], serde_json::json!([1, 2, 3]));
let back: PaginatedResponse<i32> = serde_json::from_value(json).unwrap();
assert_eq!(back, resp);
}
#[cfg(feature = "serde")]
#[test]
fn snapshot_offset_paginated_response() {
let params = PaginationParams {
limit: Some(20),
offset: Some(0),
};
let resp = PaginatedResponse::new(vec![1i32, 2, 3], 25, ¶ms);
let json = serde_json::to_value(&resp).unwrap();
let expected = serde_json::json!({
"items": [1, 2, 3],
"total_count": 25,
"has_more": true,
"limit": 20,
"offset": 0
});
assert_eq!(json, expected);
}
#[cfg(feature = "serde")]
#[test]
fn pagination_params_serde_defaults() {
let json = serde_json::json!({});
let p: PaginationParams = serde_json::from_value(json).unwrap();
assert_eq!(p.limit(), 20);
assert_eq!(p.offset(), 0);
}
#[cfg(feature = "serde")]
#[test]
fn pagination_params_serde_custom() {
let json = serde_json::json!({"limit": 50, "offset": 100});
let p: PaginationParams = serde_json::from_value(json).unwrap();
assert_eq!(p.limit(), 50);
assert_eq!(p.offset(), 100);
}
#[cfg(feature = "serde")]
#[test]
fn cursor_response_serde_omits_null_cursor() {
let resp = CursorPaginatedResponse::new(vec!["x"], CursorPagination::last_page());
let json = serde_json::to_value(&resp).unwrap();
assert!(json["pagination"].get("next_cursor").is_none());
}
#[cfg(feature = "serde")]
#[test]
fn cursor_response_serde_includes_cursor() {
let resp = CursorPaginatedResponse::new(vec!["x"], CursorPagination::more("eyJpZCI6NDJ9"));
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["pagination"]["next_cursor"], "eyJpZCI6NDJ9");
}
#[cfg(feature = "serde")]
#[test]
fn snapshot_cursor_paginated_response() {
let resp =
CursorPaginatedResponse::new(vec!["a", "b"], CursorPagination::more("eyJpZCI6NDJ9"));
let json = serde_json::to_value(&resp).unwrap();
let expected = serde_json::json!({
"data": ["a", "b"],
"pagination": {
"has_more": true,
"next_cursor": "eyJpZCI6NDJ9"
}
});
assert_eq!(json, expected);
}
#[test]
fn cursor_pagination_params_defaults() {
let p = CursorPaginationParams::default();
assert_eq!(p.limit(), 20);
assert!(p.after().is_none());
}
#[test]
fn cursor_pagination_params_none_falls_back_to_defaults() {
let p = CursorPaginationParams {
limit: None,
after: None,
};
assert_eq!(p.limit(), 20);
assert!(p.after().is_none());
}
#[test]
fn cursor_pagination_params_custom_values() {
let p = CursorPaginationParams {
limit: Some(50),
after: Some("eyJpZCI6NDJ9".to_string()),
};
assert_eq!(p.limit(), 50);
assert_eq!(p.after(), Some("eyJpZCI6NDJ9"));
}
#[cfg(feature = "validator")]
#[test]
fn cursor_pagination_params_validate_min_limit() {
use validator::Validate;
let p = CursorPaginationParams {
limit: Some(0),
after: None,
};
assert!(p.validate().is_err());
}
#[cfg(feature = "validator")]
#[test]
fn cursor_pagination_params_validate_max_limit() {
use validator::Validate;
let p = CursorPaginationParams {
limit: Some(101),
after: None,
};
assert!(p.validate().is_err());
}
#[cfg(feature = "validator")]
#[test]
fn cursor_pagination_params_validate_boundary_values() {
use validator::Validate;
let min = CursorPaginationParams {
limit: Some(1),
after: None,
};
assert!(min.validate().is_ok());
let max = CursorPaginationParams {
limit: Some(100),
after: None,
};
assert!(max.validate().is_ok());
}
#[cfg(feature = "serde")]
#[test]
fn cursor_pagination_params_serde_defaults() {
let json = serde_json::json!({});
let p: CursorPaginationParams = serde_json::from_value(json).unwrap();
assert_eq!(p.limit(), 20);
assert!(p.after().is_none());
}
#[cfg(feature = "serde")]
#[test]
fn cursor_pagination_params_serde_custom() {
let json = serde_json::json!({"limit": 50, "after": "eyJpZCI6NDJ9"});
let p: CursorPaginationParams = serde_json::from_value(json).unwrap();
assert_eq!(p.limit(), 50);
assert_eq!(p.after(), Some("eyJpZCI6NDJ9"));
}
#[cfg(feature = "schemars")]
#[test]
fn pagination_params_schema_is_valid() {
let schema = schemars::schema_for!(PaginationParams);
let json = serde_json::to_value(&schema).expect("schema serializable");
assert!(json.is_object());
}
#[cfg(all(feature = "schemars", any(feature = "std", feature = "alloc")))]
#[test]
fn cursor_pagination_schema_is_valid() {
let schema = schemars::schema_for!(CursorPagination);
let json = serde_json::to_value(&schema).expect("schema serializable");
assert!(json.is_object());
}
#[cfg(feature = "axum")]
mod axum_extractor_tests {
use super::super::{CursorPaginationParams, PaginationParams};
use axum::extract::FromRequestParts;
use axum::http::Request;
async fn extract_offset(q: &str) -> Result<PaginationParams, u16> {
let req = Request::builder().uri(format!("/?{q}")).body(()).unwrap();
let (mut parts, ()) = req.into_parts();
PaginationParams::from_request_parts(&mut parts, &())
.await
.map_err(|e| e.status)
}
async fn extract_cursor(q: &str) -> Result<CursorPaginationParams, u16> {
let req = Request::builder().uri(format!("/?{q}")).body(()).unwrap();
let (mut parts, ()) = req.into_parts();
CursorPaginationParams::from_request_parts(&mut parts, &())
.await
.map_err(|e| e.status)
}
#[tokio::test]
async fn default_params() {
let p = extract_offset("").await.unwrap();
assert_eq!(p.limit(), 20);
assert_eq!(p.offset(), 0);
}
#[tokio::test]
async fn custom_params() {
let p = extract_offset("limit=50&offset=100").await.unwrap();
assert_eq!(p.limit(), 50);
assert_eq!(p.offset(), 100);
}
#[cfg(feature = "validator")]
#[tokio::test]
async fn limit_zero_rejected() {
assert_eq!(extract_offset("limit=0").await.unwrap_err(), 400);
}
#[cfg(feature = "validator")]
#[tokio::test]
async fn limit_101_rejected() {
assert_eq!(extract_offset("limit=101").await.unwrap_err(), 400);
}
#[tokio::test]
async fn cursor_default() {
let p = extract_cursor("").await.unwrap();
assert_eq!(p.limit(), 20);
assert!(p.after().is_none());
}
#[tokio::test]
async fn cursor_custom() {
let p = extract_cursor("limit=10&after=abc").await.unwrap();
assert_eq!(p.limit(), 10);
assert_eq!(p.after(), Some("abc"));
}
#[cfg(feature = "validator")]
#[tokio::test]
async fn cursor_limit_101_rejected() {
assert_eq!(extract_cursor("limit=101").await.unwrap_err(), 400);
}
#[tokio::test]
async fn offset_invalid_query_type_rejected() {
assert_eq!(extract_offset("limit=abc").await.unwrap_err(), 400);
}
#[tokio::test]
async fn cursor_invalid_query_type_rejected() {
assert_eq!(extract_cursor("limit=abc").await.unwrap_err(), 400);
}
}
#[cfg(all(feature = "schemars", any(feature = "std", feature = "alloc")))]
#[test]
fn paginated_response_schema_is_valid() {
let schema = schemars::schema_for!(PaginatedResponse<String>);
let json = serde_json::to_value(&schema).expect("schema serializable");
assert!(json.is_object());
}
#[test]
fn keyset_params_default() {
let p = KeysetPaginationParams::<String>::default();
assert_eq!(p.limit(), 20);
assert!(p.after.is_none());
assert!(p.before.is_none());
}
#[test]
fn keyset_params_limit_none_falls_back() {
let p = KeysetPaginationParams::<u64> {
after: None,
before: None,
limit: None,
};
assert_eq!(p.limit(), 20);
}
#[test]
fn keyset_params_custom_values() {
let p = KeysetPaginationParams::<u64> {
after: Some(10),
before: Some(1),
limit: Some(50),
};
assert_eq!(p.limit(), 50);
assert_eq!(p.after, Some(10));
assert_eq!(p.before, Some(1));
}
#[test]
fn keyset_paginated_response_new() {
let resp = KeysetPaginatedResponse::new(
vec![1i32, 2, 3],
true,
false,
None,
Some("cursor_after_3".to_string()),
);
assert_eq!(resp.items, vec![1, 2, 3]);
assert!(resp.has_next);
assert!(!resp.has_prev);
assert!(resp.prev_cursor.is_none());
assert_eq!(resp.next_cursor.as_deref(), Some("cursor_after_3"));
}
#[test]
fn keyset_paginated_response_first_page() {
let resp = KeysetPaginatedResponse::first_page(
vec!["a", "b"],
true,
Some("cursor_after_b".to_string()),
);
assert!(!resp.has_prev);
assert!(resp.has_next);
assert!(resp.prev_cursor.is_none());
assert_eq!(resp.next_cursor.as_deref(), Some("cursor_after_b"));
}
#[test]
fn keyset_paginated_response_last_page() {
let resp = KeysetPaginatedResponse::first_page(vec![1i32], false, None);
assert!(!resp.has_next);
assert!(!resp.has_prev);
assert!(resp.next_cursor.is_none());
}
#[cfg(feature = "serde")]
#[test]
fn keyset_params_serde_round_trip() {
let json = serde_json::json!({"after": 5, "limit": 10});
let p: KeysetPaginationParams<u64> = serde_json::from_value(json).unwrap();
assert_eq!(p.after, Some(5));
assert_eq!(p.limit(), 10);
assert!(p.before.is_none());
}
#[cfg(feature = "serde")]
#[test]
fn keyset_params_serde_defaults() {
let json = serde_json::json!({});
let p: KeysetPaginationParams<u64> = serde_json::from_value(json).unwrap();
assert_eq!(p.limit(), 20);
assert!(p.after.is_none());
}
#[cfg(feature = "arbitrary")]
#[test]
fn arbitrary_pagination_params() {
use arbitrary::{Arbitrary, Unstructured};
let data = [0u8; 64];
let mut u = Unstructured::new(&data);
let p = PaginationParams::arbitrary(&mut u).unwrap();
assert!(p.limit.is_none());
}
#[cfg(feature = "arbitrary")]
#[test]
fn arbitrary_pagination_params_with_limit() {
use arbitrary::{Arbitrary, Unstructured};
let mut data = [0xFFu8; 64];
data[1..9].copy_from_slice(&50u64.to_le_bytes());
let mut u = Unstructured::new(&data);
let p = PaginationParams::arbitrary(&mut u).unwrap();
assert!(p.limit.is_some_and(|l| (1..=100).contains(&l)));
}
#[cfg(all(feature = "arbitrary", any(feature = "std", feature = "alloc")))]
#[test]
fn arbitrary_cursor_pagination_params() {
use arbitrary::{Arbitrary, Unstructured};
let data = [0u8; 128];
let mut u = Unstructured::new(&data);
let p = CursorPaginationParams::arbitrary(&mut u).unwrap();
assert!(p.limit.is_none());
}
#[cfg(all(feature = "arbitrary", any(feature = "std", feature = "alloc")))]
#[test]
fn arbitrary_cursor_pagination_params_with_limit() {
use arbitrary::{Arbitrary, Unstructured};
let mut data = [0xFFu8; 128];
data[1..9].copy_from_slice(&50u64.to_le_bytes());
let mut u = Unstructured::new(&data);
let p = CursorPaginationParams::arbitrary(&mut u).unwrap();
assert!(p.limit.is_some_and(|l| (1..=100).contains(&l)));
}
}