# Design: Pagination Types for api-bones
**Issue:** my-service#552
**ADR:** PLATFORM-006
**Author:** ArchMind
**Date:** 2026-03-02
**Status:** Draft — awaiting review before implementation
---
## Problem
All api-bones list endpoints currently return bare `Vec<T>`. There's no pagination, no total counts, no cursor support. As datasets grow this becomes untenable. We need a standard paginated envelope that:
1. Works for offset-based pagination (admin dashboards, tables)
2. Works for cursor-based pagination (infinite scroll, event streams)
3. Lives in `api-bones` so every service uses the same wire format
4. Stays framework-agnostic in the core, with an optional `axum` feature for extractor support
## Wire Format (target)
### Offset-based response
```json
{
"data": [{"id": "...", "name": "..."}],
"pagination": {
"total": 142,
"page": 2,
"per_page": 20,
"total_pages": 8
}
}
```
### Cursor-based response
```json
{
"data": [{"id": "...", "name": "..."}],
"pagination": {
"has_more": true,
"next_cursor": "eyJpZCI6NDJ9"
}
}
```
---
## New Feature Gates
Add to `Cargo.toml`:
```toml
[features]
default = ["serde", "uuid", "chrono"]
serde = ["dep:serde", "dep:serde_json", "dep:serde_with", "uuid?/serde"]
uuid = ["dep:uuid"]
chrono = ["dep:chrono"]
axum = ["dep:axum", "serde"] # NEW — optional Axum integration
[dependencies]
# ... existing ...
axum = { version = "0.8", optional = true, default-features = false, features = ["query"] }
```
Services that use Axum (all of them today) enable `api-bones = { features = ["axum"] }`. The core types remain usable without Axum.
---
## Type Definitions
### File: `src/pagination.rs` (new module)
```rust
//! Paginated response envelopes and query parameter extractors.
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Pagination metadata
// ---------------------------------------------------------------------------
/// Offset-based pagination metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct OffsetPagination {
/// Total number of items across all pages.
pub total: u64,
/// Current page number (1-indexed).
pub page: u32,
/// Items per page.
pub per_page: u32,
/// Total number of pages.
pub total_pages: u32,
}
/// Cursor-based pagination metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CursorPagination {
/// Whether more items exist after this page.
pub has_more: bool,
/// Opaque cursor for the next page. `None` when `has_more` is false.
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub next_cursor: Option<String>,
}
/// Pagination metadata — either offset-based or cursor-based.
///
/// Serializes *untagged* so the JSON is flat under `"pagination"`:
/// - Offset variant: `{"total": N, "page": N, "per_page": N, "total_pages": N}`
/// - Cursor variant: `{"has_more": bool, "next_cursor": "..."}`
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(untagged))]
pub enum PaginationMeta {
Offset(OffsetPagination),
Cursor(CursorPagination),
}
// ---------------------------------------------------------------------------
// Paginated response envelope
// ---------------------------------------------------------------------------
/// Generic paginated response envelope.
///
/// ```json
/// {"data": [...], "pagination": {"total": 142, "page": 2, ...}}
/// ```
///
/// `T` is the item type. The response serializes as a flat object with
/// `"data"` and `"pagination"` keys — no outer `"response"` wrapper.
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PaginatedResponse<T> {
/// The page of items.
pub data: Vec<T>,
/// Pagination metadata (offset or cursor).
pub pagination: PaginationMeta,
}
impl<T> PaginatedResponse<T> {
/// Create an offset-paginated response.
///
/// `total_pages` is computed automatically as `ceil(total / per_page)`.
pub fn offset(data: Vec<T>, total: u64, page: u32, per_page: u32) -> Self {
let total_pages = if per_page == 0 {
0
} else {
// integer ceil division
((total + u64::from(per_page) - 1) / u64::from(per_page)) as u32
};
Self {
data,
pagination: PaginationMeta::Offset(OffsetPagination {
total,
page,
per_page,
total_pages,
}),
}
}
/// Create a cursor-paginated response.
pub fn cursor(data: Vec<T>, has_more: bool, next_cursor: Option<String>) -> Self {
Self {
data,
pagination: PaginationMeta::Cursor(CursorPagination {
has_more,
next_cursor,
}),
}
}
/// Map the items in this response to a different type.
pub fn map<U>(self, f: impl FnMut(T) -> U) -> PaginatedResponse<U> {
PaginatedResponse {
data: self.data.into_iter().map(f).collect(),
pagination: self.pagination,
}
}
}
// ---------------------------------------------------------------------------
// Query parameter extractors
// ---------------------------------------------------------------------------
/// Default items per page.
const DEFAULT_PER_PAGE: u32 = 20;
/// Maximum items per page (prevents abuse).
const MAX_PER_PAGE: u32 = 100;
/// Default page number.
const DEFAULT_PAGE: u32 = 1;
/// Offset-based pagination query parameters.
///
/// Extracts `?page=2&per_page=20` from the query string.
/// Missing values get sensible defaults.
///
/// ```
/// use api_bones::OffsetParams;
///
/// let params = OffsetParams::default();
/// assert_eq!(params.page(), 1);
/// assert_eq!(params.per_page(), 20);
/// assert_eq!(params.offset(), 0);
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct OffsetParams {
/// Page number (1-indexed). Defaults to 1.
#[cfg_attr(feature = "serde", serde(default = "default_page"))]
page: u32,
/// Items per page. Defaults to 20, clamped to [1, 100].
#[cfg_attr(feature = "serde", serde(default = "default_per_page"))]
per_page: u32,
}
#[cfg(feature = "serde")]
const fn default_page() -> u32 { DEFAULT_PAGE }
#[cfg(feature = "serde")]
const fn default_per_page() -> u32 { DEFAULT_PER_PAGE }
impl Default for OffsetParams {
fn default() -> Self {
Self {
page: DEFAULT_PAGE,
per_page: DEFAULT_PER_PAGE,
}
}
}
impl OffsetParams {
/// Create with explicit page and per_page (clamped).
#[must_use]
pub fn new(page: u32, per_page: u32) -> Self {
Self {
page: page.max(1),
per_page: per_page.clamp(1, MAX_PER_PAGE),
}
}
/// Current page (1-indexed, minimum 1).
#[must_use]
pub fn page(&self) -> u32 {
self.page.max(1)
}
/// Items per page (clamped to [1, 100]).
#[must_use]
pub fn per_page(&self) -> u32 {
self.per_page.clamp(1, MAX_PER_PAGE)
}
/// SQL-style offset: `(page - 1) * per_page`.
#[must_use]
pub fn offset(&self) -> u64 {
u64::from(self.page().saturating_sub(1)) * u64::from(self.per_page())
}
/// SQL-style limit (same as `per_page()`).
#[must_use]
pub fn limit(&self) -> u64 {
u64::from(self.per_page())
}
}
/// Cursor-based pagination query parameters.
///
/// Extracts `?cursor=eyJpZCI6NDJ9&limit=20` from the query string.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CursorParams {
/// Opaque cursor from a previous response. `None` for the first page.
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub cursor: Option<String>,
/// Items to return. Defaults to 20, clamped to [1, 100].
#[cfg_attr(feature = "serde", serde(default = "default_per_page"))]
limit: u32,
}
impl Default for CursorParams {
fn default() -> Self {
Self {
cursor: None,
limit: DEFAULT_PER_PAGE,
}
}
}
impl CursorParams {
/// Create with explicit cursor and limit.
#[must_use]
pub fn new(cursor: Option<String>, limit: u32) -> Self {
Self {
cursor,
limit: limit.clamp(1, MAX_PER_PAGE),
}
}
/// Items to return (clamped to [1, 100]).
#[must_use]
pub fn limit(&self) -> u32 {
self.limit.clamp(1, MAX_PER_PAGE)
}
}
/// Unified pagination query parameters.
///
/// If `cursor` is present, cursor-based mode is used.
/// Otherwise, falls back to offset-based with `page`/`per_page`.
///
/// This allows a single query extractor for endpoints that support both modes.
///
/// ```
/// // Offset: GET /api/v1/users?page=2&per_page=10
/// // Cursor: GET /api/v1/events?cursor=eyJpZCI6NDJ9&limit=50
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct PaginationParams {
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
pub cursor: Option<String>,
#[cfg_attr(feature = "serde", serde(default = "default_page"))]
pub page: u32,
#[cfg_attr(feature = "serde", serde(default = "default_per_page"))]
pub per_page: u32,
/// Alias for `per_page` in cursor mode.
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
pub limit: Option<u32>,
}
impl Default for PaginationParams {
fn default() -> Self {
Self {
cursor: None,
page: DEFAULT_PAGE,
per_page: DEFAULT_PER_PAGE,
limit: None,
}
}
}
impl PaginationParams {
/// Returns `true` if cursor-based pagination was requested.
#[must_use]
pub fn is_cursor(&self) -> bool {
self.cursor.is_some()
}
/// Convert to offset params (ignoring cursor).
#[must_use]
pub fn as_offset(&self) -> OffsetParams {
OffsetParams::new(self.page, self.per_page)
}
/// Convert to cursor params.
#[must_use]
pub fn as_cursor(&self) -> CursorParams {
CursorParams::new(
self.cursor.clone(),
self.limit.unwrap_or(self.per_page),
)
}
}
```
### Axum Integration (`src/pagination.rs`, gated)
```rust
// ---------------------------------------------------------------------------
// Axum extractor support (feature = "axum")
// ---------------------------------------------------------------------------
#[cfg(feature = "axum")]
mod axum_support {
use super::*;
// axum::extract::Query<T> already implements FromRequestParts
// for any T: DeserializeOwned. Since OffsetParams, CursorParams,
// and PaginationParams all derive Deserialize, they work out of
// the box as:
//
// async fn list_users(
// Query(params): Query<OffsetParams>,
// ) -> impl IntoResponse { ... }
//
// No custom FromRequestParts impl needed.
// IntoResponse for PaginatedResponse<T> so handlers can return it directly.
impl<T: serde::Serialize> axum::response::IntoResponse for PaginatedResponse<T> {
fn into_response(self) -> axum::response::Response {
axum::Json(self).into_response()
}
}
}
```
### Module Registration (`src/lib.rs`)
```rust
pub mod pagination;
pub use pagination::{
CursorPagination, CursorParams, OffsetPagination, OffsetParams,
PaginatedResponse, PaginationMeta, PaginationParams,
};
```
---
## Design Decisions
### 1. Untagged enum for `PaginationMeta`
`#[serde(untagged)]` gives us clean JSON without a `"type"` discriminator. The two variants have disjoint field sets (`total`/`page` vs `has_more`/`next_cursor`), so deserialization is unambiguous.
### 2. Separate `OffsetParams` / `CursorParams` + unified `PaginationParams`
Most endpoints will use one pagination style exclusively:
- **CRUD list endpoints** → `OffsetParams` (tables with page numbers)
- **Event/audit streams** → `CursorParams` (forward-only traversal)
The unified `PaginationParams` exists for rare endpoints that support both, and for future-proofing. Endpoints pick the extractor that fits.
### 3. Framework-agnostic core, optional `axum` feature
The params types derive `Deserialize`, so `axum::extract::Query<OffsetParams>` works automatically — no custom `FromRequestParts` needed. The `axum` feature only adds `IntoResponse` for `PaginatedResponse<T>`, enabling handlers to return it directly.
### 4. Clamped values with accessors
Raw fields + accessor methods that clamp (`per_page` to [1, 100], `page` minimum 1). This prevents accidental `per_page=999999` abuse at the type level, not the handler level.
### 5. `map()` on `PaginatedResponse`
Allows converting domain types to response DTOs without reconstructing the pagination metadata:
```rust
let db_result: PaginatedResponse<UserRow> = repo.list_users(params).await?;
let response = db_result.map(UserResponse::from);
```
### 6. Constants are private, not configurable
`DEFAULT_PER_PAGE` (20) and `MAX_PER_PAGE` (100) are hardcoded. If a service needs different limits, it can clamp after extraction. Keeping these as shared defaults enforces consistency across the platform.
---
## Trait Implementations Required
| `PaginatedResponse<T>` | `Serialize` (when `T: Serialize`) | `#[cfg_attr(feature = "serde", ...)]` |
| `PaginatedResponse<T>` | `Deserialize` (when `T: Deserialize`) | For SDK/client usage |
| `PaginatedResponse<T>` | `axum::response::IntoResponse` (when `T: Serialize`) | `#[cfg(feature = "axum")]` |
| `OffsetParams`, `CursorParams`, `PaginationParams` | `Serialize + Deserialize` | Query string extraction |
| `OffsetPagination`, `CursorPagination` | `Serialize + Deserialize` | Part of response |
| `PaginationMeta` | `Serialize + Deserialize` (untagged) | Enum dispatch |
| All types | `Debug, Clone, PartialEq, Eq` | Match existing conventions (`Eq` where possible) |
Note: `PaginatedResponse<T>` gets `PartialEq` only (not `Eq`) to match `ApiError` convention — `T` may not be `Eq`.
---
## Example Usage
### Handler returning offset-paginated users
```rust
use axum::extract::{Query, State};
use api_bones::{OffsetParams, PaginatedResponse};
async fn list_users(
State(state): State<AppState>,
Query(params): Query<OffsetParams>,
) -> Result<PaginatedResponse<UserResponse>, ApiErrorResponse> {
let (users, total) = UserService::list_paginated(
&state.db,
params.offset(),
params.limit(),
).await?;
Ok(PaginatedResponse::offset(
users.into_iter().map(UserResponse::from).collect(),
total,
params.page(),
params.per_page(),
))
}
```
### Handler returning cursor-paginated events
```rust
use api_bones::{CursorParams, PaginatedResponse};
async fn list_events(
State(state): State<AppState>,
Query(params): Query<CursorParams>,
) -> Result<PaginatedResponse<EventResponse>, ApiErrorResponse> {
let (events, next_cursor) = EventService::list_after_cursor(
&state.db,
params.cursor.as_deref(),
params.limit(),
).await?;
let has_more = next_cursor.is_some();
Ok(PaginatedResponse::cursor(events, has_more, next_cursor))
}
```
### Using `map()` for DTO conversion
```rust
let page: PaginatedResponse<OrgRow> = org_repo.list(params).await?;
let response: PaginatedResponse<OrganizationResponse> = page.map(Into::into);
```
---
## Migration Path for my-service
Current list endpoints return `Json<Vec<T>>`. Migration per endpoint:
1. Add `Query(params): Query<OffsetParams>` parameter
2. Change service layer to accept `offset`/`limit` and return `(Vec<T>, u64)` (items + total count)
3. Return `PaginatedResponse::offset(...)` instead of `Json(vec)`
4. Update OpenAPI annotations (`body = PaginatedResponse<T>`)
This is backward-**incompatible** (response shape changes from `[...]` to `{"data": [...], "pagination": {...}}`). Coordinate with frontend/SDK consumers. Consider versioning or a migration period if needed.
---
## Test Plan
1. **Serde round-trip** for offset and cursor variants
2. **Wire format** assertions (exact JSON shape, field names)
3. **OffsetParams clamping**: `per_page=0` → 1, `per_page=999` → 100, `page=0` → 1
4. **OffsetParams::offset()**: page 1 → 0, page 3 with per_page 20 → 40
5. **Untagged deserialization**: offset JSON → `PaginationMeta::Offset`, cursor JSON → `PaginationMeta::Cursor`
6. **`map()`**: verify pagination metadata preserved after mapping
7. **PaginatedResponse::offset()** `total_pages` calculation: edge cases (0 items, 1 item, exact multiple)
8. **Feature gate compilation**: build with `--no-default-features`, with `serde` only, with `axum`
---
## Open Questions
1. **Should `IntoResponse` set any pagination headers?** (e.g., `X-Total-Count`, `Link` headers for RFC 8288). Current design: no — keep it simple, metadata is in the body. Revisit if needed.
2. **Should `PaginatedResponse` implement `utoipa::ToSchema`?** The my-service uses utoipa for OpenAPI. This may need a `utoipa` feature gate or manual schema impl since generic types are tricky with utoipa's derive macro. Defer to implementation phase.
3. **Cursor encoding convention?** The spec doesn't prescribe cursor format — that's intentionally left to each service (base64-encoded JSON, opaque UUID, etc.). The type is just `String`.