Skip to main content

autumn_web/
pagination.rs

1//! Standardized pagination primitives.
2//!
3//! Autumn ships two complementary flavours of pagination:
4//!
5//! 1. **Offset pagination** ([`PageRequest`] / [`Page<T>`]) — classic
6//!    `?page=N&size=M` with metadata (total elements, total pages).
7//!    Best for stable, browse-style UIs.
8//! 2. **Cursor pagination** ([`CursorRequest`] / [`CursorPage<T>`]) —
9//!    keyset/seek pagination with an opaque `next_cursor` token. Best
10//!    for real-time feeds and infinite scroll: O(1) page depth and
11//!    zero duplicates under concurrent inserts.
12//!
13//! # Quick start (offset)
14//!
15//! Paginating a handler takes three lines: run the count query, run the page
16//! query, and wrap the result in a [`Page`].
17//!
18//! ```rust,ignore
19//! use autumn_web::prelude::*;
20//! use autumn_web::pagination::{Page, PageRequest};
21//! use diesel::prelude::*;
22//! use diesel_async::RunQueryDsl;
23//!
24//! #[get("/api/posts")]
25//! async fn list(page: PageRequest, mut db: Db) -> AutumnResult<Json<Page<Post>>> {
26//!     let total: i64 = posts::table.count().get_result(&mut db).await?;
27//!     let items: Vec<Post> = posts::table
28//!         .limit(page.limit()).offset(page.offset())
29//!         .select(Post::as_select())
30//!         .load(&mut db).await?;
31//!     Ok(Json(Page::new(items, total, &page)))
32//! }
33//! ```
34//!
35//! # Quick start (cursor)
36//!
37//! Cursor pagination is keyset pagination: filter by a stable, deterministic
38//! sort key (with a unique tie-breaker like `id`), fetch `limit + 1` rows,
39//! and let [`CursorPage::from_overfetched`] derive the `next_cursor` from
40//! the boundary row.
41//!
42//! ```rust,ignore
43//! use autumn_web::prelude::*;
44//! use autumn_web::pagination::{CursorPage, CursorRequest};
45//! use chrono::{DateTime, Utc};
46//! use diesel::prelude::*;
47//! use diesel_async::RunQueryDsl;
48//! use serde::{Deserialize, Serialize};
49//!
50//! // Sort key — created_at + id is the stable, deterministic tie-breaker.
51//! #[derive(Serialize, Deserialize)]
52//! struct PostCursor { created_at: DateTime<Utc>, id: i64 }
53//!
54//! #[get("/api/feed")]
55//! async fn feed(cur: CursorRequest, mut db: Db) -> AutumnResult<Json<CursorPage<Post>>> {
56//!     let mut q = posts::table.into_boxed();
57//!     if let Some(c) = cur.decode::<PostCursor>() {
58//!         q = q.filter(
59//!             posts::created_at.lt(c.created_at)
60//!                 .or(posts::created_at.eq(c.created_at).and(posts::id.lt(c.id))),
61//!         );
62//!     }
63//!     let items: Vec<Post> = q
64//!         .order((posts::created_at.desc(), posts::id.desc()))
65//!         .limit(cur.fetch_limit())
66//!         .select(Post::as_select())
67//!         .load(&mut db).await?;
68//!     Ok(Json(CursorPage::from_overfetched(items, &cur, |p| {
69//!         PostCursor { created_at: p.created_at, id: p.id }
70//!     })))
71//! }
72//! ```
73//!
74//! # Query contract
75//!
76//! Offset pagination uses two query parameters:
77//!
78//! | Parameter | Meaning | Default | Clamped to |
79//! |-----------|---------|---------|------------|
80//! | `page` | 1-based page index | `1` | `>= 1` |
81//! | `size` | Items per page | [`DEFAULT_PAGE_SIZE`] | <code>1..=[`MAX_PAGE_SIZE`]</code> |
82//!
83//! Cursor pagination uses:
84//!
85//! | Parameter | Meaning | Default | Clamped to |
86//! |-----------|---------|---------|------------|
87//! | `cursor` | Opaque token from a prior `next_cursor` (omit for first page) | `None` | — |
88//! | `size` | Items per page | [`DEFAULT_PAGE_SIZE`] | <code>1..=[`MAX_PAGE_SIZE`]</code> |
89//!
90//! Requests like `?size=0`, `?size=9999`, `?page=0`, or even `?page=abc`
91//! are silently coerced to the valid range rather than rejected — bad
92//! pagination parameters should not 400. Unparseable or tampered cursors
93//! decode to `None` (i.e. fall back to the first page) for the same reason.
94//!
95//! # Signed cursors (optional)
96//!
97//! Plain cursors are *opaque but unsigned* — the same model used by
98//! Stripe, GitHub, and Relay. Forging one is equivalent to seeking to
99//! an arbitrary offset, which clients can already do with `?page=N`,
100//! so for sort-key-only cursors (timestamp + id) signing adds no real
101//! protection.
102//!
103//! However, if a handler ever encodes anything *sensitive to tampering*
104//! into the cursor payload — a tenant id, a user scope, anything the
105//! handler relies on to filter results — switch to the signed API:
106//!
107//! - [`Cursor::encode_signed`] / [`Cursor::decode_signed`]
108//! - [`CursorRequest::decode_signed`]
109//! - [`CursorPage::from_overfetched_signed`]
110//!
111//! All three take a key as `&[u8]`. Tokens are signed with HMAC-SHA256
112//! and verified in constant time; tampered or unsigned tokens decode
113//! to `None`.
114//!
115//! # Response shape
116//!
117//! [`Page<T>`] serializes as:
118//!
119//! ```json
120//! {
121//!   "content": [ ... ],
122//!   "page": 1,
123//!   "size": 20,
124//!   "total_elements": 137,
125//!   "total_pages": 7,
126//!   "has_next": true,
127//!   "has_previous": false
128//! }
129//! ```
130//!
131//! [`CursorPage<T>`] serializes as:
132//!
133//! ```json
134//! {
135//!   "content": [ ... ],
136//!   "size": 20,
137//!   "next_cursor": "eyJpZCI6MTIzfQ",
138//!   "has_next": true
139//! }
140//! ```
141
142use axum::extract::FromRequestParts;
143use axum::http::request::Parts;
144use axum::http::{HeaderValue, header};
145use axum::response::{IntoResponse, Response};
146use serde::de::DeserializeOwned;
147use serde::{Deserialize, Serialize};
148
149/// Default number of items per page when no `size` is provided.
150pub const DEFAULT_PAGE_SIZE: u32 = 20;
151
152/// Hard upper bound on `size` — prevents clients from requesting huge
153/// pages that could OOM the server or overwhelm the database.
154pub const MAX_PAGE_SIZE: u32 = 100;
155
156// ── PageRequest ─────────────────────────────────────────────────────
157
158/// Pagination parameters parsed from the query string.
159///
160/// Use as a handler extractor to receive `?page=N&size=M`. Both fields
161/// are optional; missing values fall back to [`DEFAULT_PAGE_SIZE`] and
162/// page `1`. Out-of-range *and unparseable* values are clamped rather
163/// than rejected: `page < 1` becomes `1`, `size` is clamped to
164/// <code>1..=[`MAX_PAGE_SIZE`]</code>, and inputs like `?page=abc` are
165/// silently ignored. A list endpoint should never 400 because of a
166/// malformed pager.
167///
168/// # Repository `page()` method
169///
170/// Every `#[repository]`-derived struct generates a `page` method that
171/// accepts a `&PageRequest` and returns a [`Page<Model>`]:
172///
173/// ```rust
174/// use autumn_web::pagination::{Page, PageRequest};
175///
176/// // Simulate what `repo.page(&req)` returns: a Page built from items +
177/// // a total row count.  This doctest exercises the public constructors
178/// // and field visibility (catches pub(crate) regressions).
179/// let req = PageRequest::new(2, 10);
180/// let items: Vec<u32> = (11..=20).collect();
181/// let page: Page<u32> = Page::new(items, 37, &req);
182///
183/// assert_eq!(page.page, 2);
184/// assert_eq!(page.size, 10);
185/// assert_eq!(page.total_elements, 37);
186/// assert_eq!(page.total_pages, 4);
187/// assert!(page.has_next);
188/// assert!(page.has_previous);
189/// assert_eq!(page.content.len(), 10);
190/// ```
191///
192/// # Handler example
193///
194/// ```rust,no_run
195/// use autumn_web::prelude::*;
196/// use autumn_web::pagination::PageRequest;
197///
198/// #[get("/api/items")]
199/// async fn list(page: PageRequest) -> String {
200///     format!("page {} (limit {}, offset {})", page.page(), page.limit(), page.offset())
201/// }
202/// ```
203#[derive(Debug, Clone, Copy, Default, Deserialize)]
204pub struct PageRequest {
205    #[serde(default)]
206    page: Option<u32>,
207    #[serde(default)]
208    size: Option<u32>,
209}
210
211impl PageRequest {
212    /// Construct a [`PageRequest`] explicitly. Values are clamped to the
213    /// valid ranges defined by [`DEFAULT_PAGE_SIZE`] / [`MAX_PAGE_SIZE`].
214    #[must_use]
215    pub const fn new(page: u32, size: u32) -> Self {
216        Self {
217            page: Some(page),
218            size: Some(size),
219        }
220    }
221
222    /// Resolved 1-based page number. `0` or missing is coerced to `1`.
223    #[must_use]
224    pub const fn page(&self) -> u32 {
225        match self.page {
226            Some(p) if p >= 1 => p,
227            _ => 1,
228        }
229    }
230
231    /// Resolved page size, clamped to <code>1..=[`MAX_PAGE_SIZE`]</code>.
232    #[must_use]
233    pub const fn size(&self) -> u32 {
234        match self.size {
235            Some(0) | None => DEFAULT_PAGE_SIZE,
236            Some(s) if s > MAX_PAGE_SIZE => MAX_PAGE_SIZE,
237            Some(s) => s,
238        }
239    }
240
241    /// `LIMIT` value for a Diesel or raw SQL query (`== size()`).
242    #[must_use]
243    pub const fn limit(&self) -> i64 {
244        self.size() as i64
245    }
246
247    /// `OFFSET` value for a Diesel or raw SQL query.
248    #[must_use]
249    pub const fn offset(&self) -> i64 {
250        ((self.page() - 1) as i64) * (self.size() as i64)
251    }
252}
253
254impl<S> FromRequestParts<S> for PageRequest
255where
256    S: Send + Sync,
257{
258    type Rejection = std::convert::Infallible;
259
260    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
261        // Manual parse rather than `Query::<Self>::from_request_parts` so
262        // that unparseable values (`?page=abc`, `?size=`, duplicate keys,
263        // percent-encoding errors) fall back to defaults instead of
264        // rejecting the whole request with a 400.
265        Ok(parts.uri.query().map_or_else(Self::default, parse_query))
266    }
267}
268
269/// Best-effort parse of a URL-encoded query string into a [`PageRequest`].
270/// Unknown keys, malformed values, and percent-decoding failures are
271/// silently ignored. Later occurrences of `page`/`size` win, matching the
272/// behaviour of `serde_urlencoded`.
273fn parse_query(query: &str) -> PageRequest {
274    let mut req = PageRequest::default();
275    for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
276        match key.as_ref() {
277            "page" => {
278                if let Ok(n) = value.parse::<u32>() {
279                    req.page = Some(n);
280                }
281            }
282            "size" => {
283                if let Ok(n) = value.parse::<u32>() {
284                    req.size = Some(n);
285                }
286            }
287            _ => {}
288        }
289    }
290    req
291}
292
293// ── SortDir ─────────────────────────────────────────────────────────
294
295/// Sort direction for an allowlisted list query column.
296///
297/// This is the single canonical sort-direction type for the framework:
298/// [`ListQuery::direction`] returns it, the `data_table` widget renders
299/// header links with it, and the repository `list()` method applies it.
300/// The `data_table` widget re-exports this same type as
301/// `autumn_web::widgets::SortDir`.
302///
303/// `Asc` is the default — an absent or unrecognized `dir=` query parameter
304/// resolves to ascending order (see [`SortDir::from_param`]).
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
306pub enum SortDir {
307    /// Ascending order (the default).
308    #[default]
309    Asc,
310    /// Descending order.
311    Desc,
312}
313
314impl SortDir {
315    /// Query-parameter value: `"asc"` or `"desc"`.
316    #[must_use]
317    pub const fn param_value(self) -> &'static str {
318        match self {
319            Self::Asc => "asc",
320            Self::Desc => "desc",
321        }
322    }
323
324    /// `aria-sort` attribute value: `"ascending"` or `"descending"`.
325    #[must_use]
326    pub const fn aria_value(self) -> &'static str {
327        match self {
328            Self::Asc => "ascending",
329            Self::Desc => "descending",
330        }
331    }
332
333    /// Returns the opposite direction.
334    #[must_use]
335    pub const fn toggled(self) -> Self {
336        match self {
337            Self::Asc => Self::Desc,
338            Self::Desc => Self::Asc,
339        }
340    }
341
342    /// Parse a `dir=` query value. Only `"desc"` (case-insensitive) selects
343    /// [`SortDir::Desc`]; **every other value — including an empty string, a
344    /// typo, or a missing parameter — falls back to [`SortDir::Asc`]**. A list
345    /// endpoint must never 400 on a malformed direction.
346    #[must_use]
347    pub fn from_param(value: &str) -> Self {
348        if value.eq_ignore_ascii_case("desc") {
349            Self::Desc
350        } else {
351            Self::Asc
352        }
353    }
354}
355
356// ── ListQuery ───────────────────────────────────────────────────────
357
358/// Sort/filter parameters parsed from the query string, composing with
359/// [`PageRequest`] to drive an allowlisted list query.
360///
361/// `ListQuery` is the request half of the safe sort/filter feature. It parses
362/// three families of query parameters, all optional and all best-effort:
363///
364/// | Parameter | Meaning | Default |
365/// |-----------|---------|---------|
366/// | `sort` | Column key to order by | model's default order (primary key) |
367/// | `dir` | `asc` or `desc` | `asc` (anything unrecognized → `asc`) |
368/// | `filter[<col>]` | Equality filter on column `<col>` | none |
369///
370/// # The allowlist is the security boundary
371///
372/// `ListQuery` itself performs **no** validation of column names — it only
373/// carries the raw request intent. The safety guarantee lives in the
374/// repository `list()` method generated by `#[repository]`: that method
375/// matches the requested `sort`/`filter[..]` keys against the model's own
376/// columns using Diesel's typed DSL. A key that is not a real column hits the
377/// default match arm and is **silently ignored** — it can never be
378/// interpolated into SQL. This is why an attacker-supplied
379/// `?sort=id;DROP TABLE users` is inert: `id;DROP TABLE users` is not a
380/// column, so it falls through to the model's default ordering.
381///
382/// Because the extractor is [`Infallible`](std::convert::Infallible) it never
383/// rejects a request: an empty `sort` falls back to the default order, an
384/// invalid `dir` falls back to `asc`, and unknown parameters are dropped —
385/// mirroring [`PageRequest`]'s forgiving posture.
386///
387/// # Example
388///
389/// ```rust,ignore
390/// use autumn_web::prelude::*;
391/// use autumn_web::pagination::{ListQuery, PageRequest};
392///
393/// // GET /posts?sort=title&dir=desc&filter[published]=true&page=2
394/// #[get("/posts")]
395/// async fn index(
396///     list_query: ListQuery,
397///     page_req: PageRequest,
398///     repo: PgPostRepository,
399/// ) -> AutumnResult<Json<Page<Post>>> {
400///     // `list()` applies only allowlisted columns; unknown keys are ignored.
401///     let page = repo.list(&list_query, &page_req).await?;
402///     Ok(Json(page))
403/// }
404/// ```
405#[derive(Debug, Clone, Default)]
406pub struct ListQuery {
407    sort: Option<String>,
408    dir: SortDir,
409    filters: Vec<(String, String)>,
410}
411
412impl ListQuery {
413    /// Construct a [`ListQuery`] explicitly. Useful in tests and when driving
414    /// `list()` from code rather than a request.
415    #[must_use]
416    pub fn new(sort: Option<&str>, dir: SortDir, filters: &[(&str, &str)]) -> Self {
417        Self {
418            sort: sort.map(str::to_owned),
419            dir,
420            filters: filters
421                .iter()
422                .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
423                .collect(),
424        }
425    }
426
427    /// The requested sort column key, if any.
428    ///
429    /// This is the *raw* requested key — it may name a column that is not in
430    /// the model's allowlist, in which case `list()` ignores it and falls back
431    /// to the default order. Returns `None` when no (or an empty) `sort=` was
432    /// provided.
433    #[must_use]
434    pub fn sort(&self) -> Option<&str> {
435        self.sort.as_deref()
436    }
437
438    /// The resolved sort direction, defaulting to [`SortDir::Asc`].
439    #[must_use]
440    pub const fn direction(&self) -> SortDir {
441        self.dir
442    }
443
444    /// Iterate over the requested equality filters as `(column, value)` pairs.
445    ///
446    /// As with [`sort`](Self::sort), these are raw requested keys: `list()`
447    /// applies only the ones that name a real, filterable column and silently
448    /// drops the rest.
449    pub fn filters(&self) -> impl Iterator<Item = (&str, &str)> {
450        self.filters.iter().map(|(k, v)| (k.as_str(), v.as_str()))
451    }
452
453    /// `true` when no sort, direction override, or filters were requested.
454    #[must_use]
455    pub fn is_empty(&self) -> bool {
456        self.sort.is_none() && self.dir == SortDir::Asc && self.filters.is_empty()
457    }
458}
459
460/// No-op fallback for the repository `list()` sort/filter allowlist.
461///
462/// The `#[model]` macro generates **typed, per-column** inherent
463/// `__autumn_list_apply_order` / `__autumn_list_apply_filters` associated
464/// functions on the model struct; those take precedence over the no-op defaults
465/// here. A `#[repository]` declared on a hand-written model (one not produced by
466/// `#[model]`, so with no column metadata) falls back to these no-ops: `list()`
467/// still paginates, but applies no sort/filter — there is no column allowlist to
468/// enforce, so nothing from the request touches the query. This keeps `list()`
469/// available on every repository while guaranteeing the allowlist is the only
470/// path a column name can reach SQL.
471#[doc(hidden)]
472pub trait AutumnListable {
473    #[doc(hidden)]
474    fn __autumn_list_apply_order<Q>(query_builder: Q, _list: &ListQuery) -> Q {
475        query_builder
476    }
477    #[doc(hidden)]
478    fn __autumn_list_apply_filters<Q>(query_builder: Q, _list: &ListQuery) -> Q {
479        query_builder
480    }
481}
482
483impl<T> AutumnListable for T {}
484
485impl<S> FromRequestParts<S> for ListQuery
486where
487    S: Send + Sync,
488{
489    type Rejection = std::convert::Infallible;
490
491    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
492        // Best-effort parse, mirroring `PageRequest`: a list endpoint must
493        // never 400 on a malformed sort/filter parameter.
494        Ok(parts
495            .uri
496            .query()
497            .map_or_else(Self::default, parse_list_query))
498    }
499}
500
501/// Best-effort parse of a URL-encoded query string into a [`ListQuery`].
502///
503/// - `sort=<key>` — the last non-empty occurrence wins.
504/// - `dir=<asc|desc>` — anything other than `desc` (case-insensitive) → `asc`.
505/// - `filter[<col>]=<val>` — every occurrence is collected; an empty `<col>`
506///   is dropped. Duplicate columns are all kept (the allowlist layer applies
507///   each in turn; the last equality filter for a column effectively wins at
508///   the SQL level).
509///
510/// Unknown keys (including `page`/`size`, which belong to [`PageRequest`]) are
511/// ignored so the two extractors compose without interference.
512fn parse_list_query(query: &str) -> ListQuery {
513    let mut req = ListQuery::default();
514    for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
515        let key = key.as_ref();
516        if key == "sort" {
517            if value.is_empty() {
518                req.sort = None;
519            } else {
520                req.sort = Some(value.into_owned());
521            }
522        } else if key == "dir" {
523            req.dir = SortDir::from_param(&value);
524        } else if let Some(col) = key
525            .strip_prefix("filter[")
526            .and_then(|rest| rest.strip_suffix(']'))
527            && !col.is_empty()
528        {
529            req.filters.push((col.to_owned(), value.into_owned()));
530        }
531    }
532    req
533}
534
535// ── Page<T> ─────────────────────────────────────────────────────────
536
537/// Paginated response wrapper with navigation metadata.
538///
539/// `Page` serializes to JSON for API responses and exposes the fields a
540/// Maud template needs to render pager links (previous/next, page index,
541/// total pages).
542///
543/// Construct one with [`Page::new`] after running your count + page
544/// queries, or with [`Page::empty`] when you have no data to return.
545///
546/// # JSON shape
547///
548/// ```json
549/// {
550///   "content": [ /* T items */ ],
551///   "page": 1,
552///   "size": 20,
553///   "total_elements": 137,
554///   "total_pages": 7,
555///   "has_next": true,
556///   "has_previous": false
557/// }
558/// ```
559#[derive(Debug, Clone, Serialize, Deserialize)]
560pub struct Page<T> {
561    /// The items on this page.
562    pub content: Vec<T>,
563    /// Current 1-based page index.
564    pub page: u32,
565    /// Page size used to produce `content`.
566    pub size: u32,
567    /// Total number of items across every page.
568    pub total_elements: u64,
569    /// Total number of pages (`ceil(total_elements / size)`), minimum `1`.
570    pub total_pages: u32,
571    /// Whether there is a page after this one.
572    pub has_next: bool,
573    /// Whether there is a page before this one.
574    pub has_previous: bool,
575}
576
577impl<T> Page<T> {
578    /// Build a page from the materialized `items` and the total row
579    /// count returned by the database.
580    ///
581    /// `total` is accepted as `i64` to match Diesel's
582    /// `COUNT(*)` result type; values below zero are treated as zero.
583    #[must_use]
584    pub fn new(items: Vec<T>, total: i64, request: &PageRequest) -> Self {
585        let size = request.size();
586        let page = request.page();
587        let total_elements = u64::try_from(total).unwrap_or(0);
588
589        // ceil(total / size), minimum 1 so an empty result still
590        // reports `total_pages = 1` — callers don't have to branch on
591        // "no rows" when rendering a pager.
592        let total_pages = if total_elements == 0 {
593            1
594        } else {
595            // size() is always >= 1, so this division is safe.
596            u32::try_from(total_elements.div_ceil(u64::from(size))).unwrap_or(u32::MAX)
597        };
598
599        Self {
600            content: items,
601            page,
602            size,
603            total_elements,
604            total_pages,
605            has_next: page < total_pages,
606            has_previous: page > 1,
607        }
608    }
609
610    /// Build an empty page using the caller's request parameters.
611    ///
612    /// Useful when a filter short-circuits before hitting the database.
613    #[must_use]
614    pub fn empty(request: &PageRequest) -> Self {
615        Self::new(Vec::new(), 0, request)
616    }
617
618    /// Build a metadata-only page from raw pagination counters, without a
619    /// `PageRequest`. Useful for bridging external count types (e.g. `u64`)
620    /// into the standard `Page` shape for rendering or serialisation.
621    ///
622    /// `page` is clamped to `[1, u32::MAX]`; `total_pages` is clamped to
623    /// at least `1` so callers don't have to special-case empty result sets.
624    /// `content` is empty — use [`Page::new`] when you have items.
625    #[must_use]
626    pub fn from_raw(page: u32, size: u32, total_elements: u64, total_pages: u32) -> Self {
627        let page = page.max(1);
628        let total_pages = total_pages.max(1);
629        Self {
630            content: Vec::new(),
631            page,
632            size,
633            total_elements,
634            total_pages,
635            has_next: page < total_pages,
636            has_previous: page > 1,
637        }
638    }
639
640    /// Paginate an already fully-materialized collection in memory.
641    ///
642    /// Used when every row must be loaded before the page window can be
643    /// applied — for example a list endpoint that filters each row through a
644    /// per-row authorization policy (the filter has to run in Rust, so a
645    /// SQL `LIMIT/OFFSET` can't bound the result on its own). The returned
646    /// [`Page`] carries the full `total_elements` (so `total_pages` and the
647    /// nav links are correct) but only the `request`-selected window in
648    /// `content`.
649    ///
650    /// Prefer [`Page::new`] with a SQL `LIMIT/OFFSET` + `COUNT(*)` whenever
651    /// the window can be pushed into the database.
652    #[must_use]
653    pub fn paginate_in_memory(all: Vec<T>, request: &PageRequest) -> Self {
654        let total = i64::try_from(all.len()).unwrap_or(i64::MAX);
655        let offset = usize::try_from(request.offset()).unwrap_or(usize::MAX);
656        let size = request.size() as usize;
657        let content: Vec<T> = all.into_iter().skip(offset).take(size).collect();
658        Self::new(content, total, request)
659    }
660
661    /// Transform the content while preserving pagination metadata.
662    ///
663    /// Typical use: converting database rows into DTOs for JSON output
664    /// without re-running the count query.
665    pub fn map<U, F: FnMut(T) -> U>(self, f: F) -> Page<U> {
666        Page {
667            content: self.content.into_iter().map(f).collect(),
668            page: self.page,
669            size: self.size,
670            total_elements: self.total_elements,
671            total_pages: self.total_pages,
672            has_next: self.has_next,
673            has_previous: self.has_previous,
674        }
675    }
676}
677
678/// Build an RFC 8288 `Link` header value for an offset [`Page`].
679///
680/// Uses *relative* query-only references (`<?page=2&size=20>`) which a client
681/// resolves against the request URL — so the response doesn't need to know its
682/// own mount path. `first` and `last` are always emitted (offset pagination
683/// always knows both bounds); `prev`/`next` only when they exist.
684fn page_link_header_value(page: u32, size: u32, total_pages: u32, has_next: bool) -> String {
685    let mut links: Vec<String> = Vec::with_capacity(4);
686    let rel = |target: u32, name: &str| format!("<?page={target}&size={size}>; rel=\"{name}\"");
687    links.push(rel(1, "first"));
688    if page > 1 {
689        links.push(rel(page - 1, "prev"));
690    }
691    if has_next {
692        links.push(rel(page + 1, "next"));
693    }
694    // For an empty collection `total_pages` is 0; clamp to 1 so the `last`
695    // link stays a valid 1-indexed page rather than `page=0`.
696    let last_page = total_pages.max(1);
697    links.push(rel(last_page, "last"));
698    links.join(", ")
699}
700
701impl<T> IntoResponse for Page<T>
702where
703    T: Serialize,
704{
705    fn into_response(self) -> Response {
706        // Snapshot the nav counters before `self` is moved into `Json`.
707        let (page, size, total_pages, has_next) =
708            (self.page, self.size, self.total_pages, self.has_next);
709        let mut response = axum::Json(self).into_response();
710        if let Ok(value) =
711            HeaderValue::from_str(&page_link_header_value(page, size, total_pages, has_next))
712        {
713            response.headers_mut().insert(header::LINK, value);
714        }
715        response
716    }
717}
718
719impl<T> IntoResponse for CursorPage<T>
720where
721    T: Serialize,
722{
723    fn into_response(self) -> Response {
724        // Only a `next` link is meaningful for keyset pagination — there is no
725        // cheap way back to `first`/`prev`/`last` without a full scan.
726        let next_link = self
727            .next_cursor
728            .as_ref()
729            .filter(|_| self.has_next)
730            .map(|token| {
731                let encoded: String =
732                    url::form_urlencoded::byte_serialize(token.as_bytes()).collect();
733                format!("<?cursor={encoded}&size={}>; rel=\"next\"", self.size)
734            });
735        let mut response = axum::Json(self).into_response();
736        if let Some(link) = next_link
737            && let Ok(value) = HeaderValue::from_str(&link)
738        {
739            response.headers_mut().insert(header::LINK, value);
740        }
741        response
742    }
743}
744
745// ── Cursor encoding ─────────────────────────────────────────────────
746//
747// Cursor tokens are base64url-encoded JSON. base64url is used (rather
748// than the standard alphabet) so that tokens are safe to embed in a
749// URL without percent-encoding, and padding (`=`) is omitted to keep
750// them tidy. Tokens are *opaque* to clients — encoding the structure
751// keeps callers from forging cursors but, more importantly, lets the
752// server change the schema without breaking the wire contract.
753
754const BASE64URL_ALPHABET: &[u8; 64] =
755    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
756
757fn base64url_encode(input: &[u8]) -> String {
758    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
759    let mut chunks = input.chunks_exact(3);
760    for chunk in &mut chunks {
761        let n = (u32::from(chunk[0]) << 16) | (u32::from(chunk[1]) << 8) | u32::from(chunk[2]);
762        out.push(BASE64URL_ALPHABET[((n >> 18) & 0x3F) as usize] as char);
763        out.push(BASE64URL_ALPHABET[((n >> 12) & 0x3F) as usize] as char);
764        out.push(BASE64URL_ALPHABET[((n >> 6) & 0x3F) as usize] as char);
765        out.push(BASE64URL_ALPHABET[(n & 0x3F) as usize] as char);
766    }
767    let rem = chunks.remainder();
768    match rem.len() {
769        0 => {}
770        1 => {
771            let n = u32::from(rem[0]) << 16;
772            out.push(BASE64URL_ALPHABET[((n >> 18) & 0x3F) as usize] as char);
773            out.push(BASE64URL_ALPHABET[((n >> 12) & 0x3F) as usize] as char);
774        }
775        2 => {
776            let n = (u32::from(rem[0]) << 16) | (u32::from(rem[1]) << 8);
777            out.push(BASE64URL_ALPHABET[((n >> 18) & 0x3F) as usize] as char);
778            out.push(BASE64URL_ALPHABET[((n >> 12) & 0x3F) as usize] as char);
779            out.push(BASE64URL_ALPHABET[((n >> 6) & 0x3F) as usize] as char);
780        }
781        _ => unreachable!("chunks_exact remainder is < 3 by construction"),
782    }
783    out
784}
785
786fn base64url_decode(input: &str) -> Option<Vec<u8>> {
787    let bytes = input.as_bytes();
788    let value = |b: u8| -> Option<u32> {
789        match b {
790            b'A'..=b'Z' => Some(u32::from(b - b'A')),
791            b'a'..=b'z' => Some(u32::from(b - b'a') + 26),
792            b'0'..=b'9' => Some(u32::from(b - b'0') + 52),
793            b'-' => Some(62),
794            b'_' => Some(63),
795            _ => None,
796        }
797    };
798    let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
799    let mut i = 0;
800    while i + 4 <= bytes.len() {
801        let n = (value(bytes[i])? << 18)
802            | (value(bytes[i + 1])? << 12)
803            | (value(bytes[i + 2])? << 6)
804            | value(bytes[i + 3])?;
805        out.push(u8::try_from((n >> 16) & 0xFF).ok()?);
806        out.push(u8::try_from((n >> 8) & 0xFF).ok()?);
807        out.push(u8::try_from(n & 0xFF).ok()?);
808        i += 4;
809    }
810    match bytes.len() - i {
811        0 => {}
812        1 => return None, // a single trailing char is not a valid base64 group
813        2 => {
814            let n = (value(bytes[i])? << 18) | (value(bytes[i + 1])? << 12);
815            out.push(u8::try_from((n >> 16) & 0xFF).ok()?);
816        }
817        3 => {
818            let n = (value(bytes[i])? << 18)
819                | (value(bytes[i + 1])? << 12)
820                | (value(bytes[i + 2])? << 6);
821            out.push(u8::try_from((n >> 16) & 0xFF).ok()?);
822            out.push(u8::try_from((n >> 8) & 0xFF).ok()?);
823        }
824        _ => unreachable!("bytes.len() - i is < 4 by the loop condition"),
825    }
826    Some(out)
827}
828
829/// Opaque cursor tokens for cursor-based pagination.
830///
831/// A cursor wraps a serializable sort-key payload (typically a struct
832/// containing a timestamp and a unique tie-breaker like `id`) into a
833/// URL-safe string. The on-the-wire format is base64url-encoded JSON;
834/// callers should treat tokens as opaque.
835///
836/// # Examples
837///
838/// ```rust
839/// use autumn_web::pagination::Cursor;
840/// use serde::{Deserialize, Serialize};
841///
842/// #[derive(Serialize, Deserialize, PartialEq, Debug)]
843/// struct Key { id: i64 }
844///
845/// let token = Cursor::encode(&Key { id: 42 }).unwrap();
846/// let decoded: Key = Cursor::decode(&token).unwrap();
847/// assert_eq!(decoded, Key { id: 42 });
848/// ```
849pub struct Cursor;
850
851impl Cursor {
852    /// Encode a serializable value as an opaque URL-safe cursor token.
853    ///
854    /// # Errors
855    ///
856    /// Returns `serde_json::Error` if the value cannot be serialized.
857    pub fn encode<T: Serialize>(value: &T) -> Result<String, serde_json::Error> {
858        Ok(base64url_encode(&serde_json::to_vec(value)?))
859    }
860
861    /// Decode an opaque cursor token back into a typed value.
862    ///
863    /// Returns `None` for any malformed token — invalid base64, invalid
864    /// UTF-8, or JSON that doesn't match `T`. A list endpoint should
865    /// silently fall back to the first page rather than 400 on a
866    /// tampered or stale cursor, matching the forgiving behaviour of
867    /// the `?page=` / `?size=` parser.
868    #[must_use]
869    pub fn decode<T: DeserializeOwned>(token: &str) -> Option<T> {
870        let bytes = base64url_decode(token)?;
871        serde_json::from_slice(&bytes).ok()
872    }
873
874    /// Encode a value as a *signed* cursor token using HMAC-SHA256.
875    ///
876    /// Use this when the cursor payload encodes anything sensitive to
877    /// tampering — for example a tenant boundary, a user id, or any
878    /// scope a handler relies on to filter results. Without signing,
879    /// a client could edit the JSON and re-encode it. Plain
880    /// [`Cursor::encode`] is fine for cursors that only carry sort-key
881    /// values (timestamps, primary keys), since forging one is
882    /// equivalent to seeking to an arbitrary offset.
883    ///
884    /// The token format is `<base64url(json)>.<base64url(hmac)>`.
885    /// The signature covers exactly the payload bytes; the encoded
886    /// payload itself is not encrypted (cursors are not secrets).
887    ///
888    /// # Errors
889    ///
890    /// Returns `serde_json::Error` if the value cannot be serialized.
891    pub fn encode_signed<T: Serialize>(value: &T, key: &[u8]) -> Result<String, serde_json::Error> {
892        let payload = serde_json::to_vec(value)?;
893        let payload_b64 = base64url_encode(&payload);
894        let mac = hmac_sha256(key, payload_b64.as_bytes());
895        let sig_b64 = base64url_encode(&mac);
896        Ok(format!("{payload_b64}.{sig_b64}"))
897    }
898
899    /// Decode a signed cursor token, verifying its HMAC-SHA256 signature.
900    ///
901    /// Returns `None` for any of: malformed structure, malformed
902    /// base64, signature mismatch, JSON that doesn't match `T`. The
903    /// signature is verified in constant time. A handler that uses
904    /// this should treat `None` the same way it treats no cursor
905    /// (fall back to first page) rather than returning an error —
906    /// the goal is to ignore tampered cursors, not to surface them
907    /// as user-facing failures.
908    #[must_use]
909    pub fn decode_signed<T: DeserializeOwned>(token: &str, key: &[u8]) -> Option<T> {
910        let (payload_b64, sig_b64) = token.split_once('.')?;
911        let expected_sig = base64url_decode(sig_b64)?;
912        let actual_sig = hmac_sha256(key, payload_b64.as_bytes());
913        if !constant_time_eq(&expected_sig, &actual_sig) {
914            return None;
915        }
916        let payload = base64url_decode(payload_b64)?;
917        serde_json::from_slice(&payload).ok()
918    }
919}
920
921fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
922    use hmac::{Hmac, Mac};
923    use sha2::Sha256;
924    // `Hmac::new_from_slice` accepts any key length.
925    let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC accepts any key length");
926    mac.update(message);
927    mac.finalize().into_bytes().into()
928}
929
930fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
931    use subtle::ConstantTimeEq;
932    a.ct_eq(b).into()
933}
934
935// ── CursorRequest ───────────────────────────────────────────────────
936
937/// Cursor pagination parameters parsed from the query string.
938///
939/// Use as a handler extractor to receive `?cursor=<token>&size=<n>`.
940/// Both fields are optional: missing `cursor` means "give me the
941/// first page", missing `size` means [`DEFAULT_PAGE_SIZE`]. The same
942/// forgiving coercion as [`PageRequest`] applies — `?size=0` falls
943/// back to the default, `?size=9999` is clamped to [`MAX_PAGE_SIZE`],
944/// and unparseable values are silently ignored. A malformed cursor is
945/// treated as no cursor.
946///
947/// # Examples
948///
949/// ```rust,no_run
950/// use autumn_web::prelude::*;
951/// use autumn_web::pagination::CursorRequest;
952///
953/// #[get("/api/feed")]
954/// async fn feed(cur: CursorRequest) -> String {
955///     format!("size={}, cursor={:?}", cur.size(), cur.cursor())
956/// }
957/// ```
958#[derive(Debug, Clone, Default, Deserialize)]
959pub struct CursorRequest {
960    #[serde(default)]
961    cursor: Option<String>,
962    #[serde(default)]
963    size: Option<u32>,
964}
965
966impl CursorRequest {
967    /// Construct a [`CursorRequest`] explicitly. Useful in tests.
968    #[must_use]
969    pub const fn new(cursor: Option<String>, size: u32) -> Self {
970        Self {
971            cursor,
972            size: Some(size),
973        }
974    }
975
976    /// The raw, opaque cursor token, if any.
977    #[must_use]
978    pub fn cursor(&self) -> Option<&str> {
979        self.cursor.as_deref()
980    }
981
982    /// Decode the cursor into a typed sort-key value.
983    ///
984    /// Returns `None` if the cursor is missing or unparseable. Use
985    /// this in handlers to add the keyset filter to the query.
986    #[must_use]
987    pub fn decode<T: DeserializeOwned>(&self) -> Option<T> {
988        Cursor::decode(self.cursor.as_deref()?)
989    }
990
991    /// Decode a *signed* cursor, verifying its HMAC-SHA256 signature
992    /// against `key`. See [`Cursor::decode_signed`] for the threat
993    /// model and when to use signed vs. unsigned cursors.
994    ///
995    /// Returns `None` if the cursor is missing, malformed, or has an
996    /// invalid signature.
997    #[must_use]
998    pub fn decode_signed<T: DeserializeOwned>(&self, key: &[u8]) -> Option<T> {
999        Cursor::decode_signed(self.cursor.as_deref()?, key)
1000    }
1001
1002    /// Resolved page size, clamped to <code>1..=[`MAX_PAGE_SIZE`]</code>.
1003    #[must_use]
1004    pub const fn size(&self) -> u32 {
1005        match self.size {
1006            Some(0) | None => DEFAULT_PAGE_SIZE,
1007            Some(s) if s > MAX_PAGE_SIZE => MAX_PAGE_SIZE,
1008            Some(s) => s,
1009        }
1010    }
1011
1012    /// `LIMIT` value matching the requested page size.
1013    #[must_use]
1014    pub const fn limit(&self) -> i64 {
1015        self.size() as i64
1016    }
1017
1018    /// `LIMIT + 1` — fetch one extra row so the handler can detect
1019    /// whether a next page exists without an extra query.
1020    ///
1021    /// This is the value to pass to Diesel's `.limit(...)` when using
1022    /// [`CursorPage::from_overfetched`].
1023    #[must_use]
1024    pub const fn fetch_limit(&self) -> i64 {
1025        self.size() as i64 + 1
1026    }
1027}
1028
1029impl<S> FromRequestParts<S> for CursorRequest
1030where
1031    S: Send + Sync,
1032{
1033    type Rejection = std::convert::Infallible;
1034
1035    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
1036        Ok(parts
1037            .uri
1038            .query()
1039            .map_or_else(Self::default, parse_cursor_query))
1040    }
1041}
1042
1043/// Best-effort parse of a URL-encoded query string into a [`CursorRequest`].
1044/// Same coercion rules as [`parse_query`]: unknown keys, malformed values,
1045/// and percent-decoding failures are silently ignored. Later occurrences of
1046/// `cursor`/`size` win.
1047fn parse_cursor_query(query: &str) -> CursorRequest {
1048    let mut req = CursorRequest::default();
1049    for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
1050        match key.as_ref() {
1051            "cursor" if !value.is_empty() => {
1052                req.cursor = Some(value.into_owned());
1053            }
1054            "size" => {
1055                if let Ok(n) = value.parse::<u32>() {
1056                    req.size = Some(n);
1057                }
1058            }
1059            _ => {}
1060        }
1061    }
1062    req
1063}
1064
1065// ── CursorPage<T> ───────────────────────────────────────────────────
1066
1067/// Paginated response wrapper for cursor-based pagination.
1068///
1069/// `CursorPage` serializes to JSON for API responses and is the
1070/// counterpart to [`Page<T>`] for keyset/seek pagination. Construct
1071/// one with [`CursorPage::from_overfetched`] after running a single
1072/// `LIMIT n+1` query.
1073///
1074/// # JSON shape
1075///
1076/// ```json
1077/// {
1078///   "content": [ /* T items */ ],
1079///   "size": 20,
1080///   "next_cursor": "eyJpZCI6MTIzfQ",
1081///   "has_next": true
1082/// }
1083/// ```
1084///
1085/// `next_cursor` is `null` on the last page (`has_next == false`).
1086#[derive(Debug, Clone, Serialize, Deserialize)]
1087pub struct CursorPage<T> {
1088    /// The items on this page.
1089    pub content: Vec<T>,
1090    /// Page size used to produce `content`.
1091    pub size: u32,
1092    /// Opaque cursor for the next page, or `None` if this is the last page.
1093    pub next_cursor: Option<String>,
1094    /// Whether there is a page after this one.
1095    pub has_next: bool,
1096}
1097
1098impl<T> CursorPage<T> {
1099    /// Build a page from an over-fetched result set.
1100    ///
1101    /// The caller fetches `request.fetch_limit()` rows (one more than
1102    /// the page size). If that returned the extra row, this method
1103    /// truncates `content` back to the page size, marks `has_next`
1104    /// true, and derives `next_cursor` from the *last kept* item via
1105    /// `cursor_fn`. If fewer rows came back, this is the last page
1106    /// and `next_cursor` is `None`.
1107    ///
1108    /// `cursor_fn` is called at most once.
1109    ///
1110    /// # Errors / panics
1111    ///
1112    /// If `cursor_fn`'s value fails to serialize (extremely unlikely
1113    /// for the simple sort-key structs typically used here),
1114    /// `next_cursor` is set to `None` and `has_next` to `false`. The
1115    /// page is still returned — the alternative would be a 500 on a
1116    /// successful query, which is the wrong tradeoff for a list
1117    /// endpoint.
1118    #[must_use]
1119    pub fn from_overfetched<K, F>(items: Vec<T>, request: &CursorRequest, cursor_fn: F) -> Self
1120    where
1121        K: Serialize,
1122        F: FnOnce(&T) -> K,
1123    {
1124        Self::from_overfetched_inner(items, request, cursor_fn, |k| Cursor::encode(&k).ok())
1125    }
1126
1127    /// Variant of [`Self::from_overfetched`] that signs `next_cursor`
1128    /// with HMAC-SHA256 using `key`.
1129    ///
1130    /// Use this when the cursor payload encodes anything sensitive to
1131    /// tampering (tenant ids, user scopes). For sort-key-only cursors
1132    /// (timestamp + id), plain [`Self::from_overfetched`] is fine —
1133    /// forging an unsigned cursor is equivalent to seeking to an
1134    /// arbitrary offset, which clients can already do with `?page=N`.
1135    ///
1136    /// The corresponding extractor side calls
1137    /// [`CursorRequest::decode_signed`] with the same key.
1138    #[must_use]
1139    pub fn from_overfetched_signed<K, F>(
1140        items: Vec<T>,
1141        request: &CursorRequest,
1142        key: &[u8],
1143        cursor_fn: F,
1144    ) -> Self
1145    where
1146        K: Serialize,
1147        F: FnOnce(&T) -> K,
1148    {
1149        Self::from_overfetched_inner(items, request, cursor_fn, |k| {
1150            Cursor::encode_signed(&k, key).ok()
1151        })
1152    }
1153
1154    fn from_overfetched_inner<K, F, E>(
1155        mut items: Vec<T>,
1156        request: &CursorRequest,
1157        cursor_fn: F,
1158        encode: E,
1159    ) -> Self
1160    where
1161        K: Serialize,
1162        F: FnOnce(&T) -> K,
1163        E: FnOnce(K) -> Option<String>,
1164    {
1165        let size = request.size();
1166        let limit = size as usize;
1167        let has_next = items.len() > limit;
1168        if has_next {
1169            items.truncate(limit);
1170        }
1171        let next_cursor = if has_next {
1172            // The boundary row is the last one we kept. Encoding from
1173            // it (rather than the popped row) means the next query
1174            // can use a strict inequality and still see every row,
1175            // even under concurrent inserts that land between pages.
1176            items.last().map(cursor_fn).and_then(encode)
1177        } else {
1178            None
1179        };
1180        // If encoding failed for some reason, don't claim a next page
1181        // we can't actually serve.
1182        let has_next = has_next && next_cursor.is_some();
1183        Self {
1184            content: items,
1185            size,
1186            next_cursor,
1187            has_next,
1188        }
1189    }
1190
1191    /// Build an empty page using the caller's request parameters.
1192    ///
1193    /// Useful when a filter short-circuits before hitting the database.
1194    #[must_use]
1195    pub const fn empty(request: &CursorRequest) -> Self {
1196        Self {
1197            content: Vec::new(),
1198            size: request.size(),
1199            next_cursor: None,
1200            has_next: false,
1201        }
1202    }
1203
1204    /// Transform the content while preserving pagination metadata.
1205    pub fn map<U, F: FnMut(T) -> U>(self, f: F) -> CursorPage<U> {
1206        CursorPage {
1207            content: self.content.into_iter().map(f).collect(),
1208            size: self.size,
1209            next_cursor: self.next_cursor,
1210            has_next: self.has_next,
1211        }
1212    }
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217    use super::*;
1218    use axum::Router;
1219    use axum::body::Body;
1220    use axum::http::{Request, StatusCode};
1221    use axum::routing::get;
1222    use tower::ServiceExt;
1223
1224    // ── PageRequest coercion ────────────────────────────────────
1225
1226    #[test]
1227    fn defaults_when_nothing_provided() {
1228        let r = PageRequest::default();
1229        assert_eq!(r.page(), 1);
1230        assert_eq!(r.size(), DEFAULT_PAGE_SIZE);
1231        assert_eq!(r.limit(), i64::from(DEFAULT_PAGE_SIZE));
1232        assert_eq!(r.offset(), 0);
1233    }
1234
1235    #[test]
1236    fn page_zero_is_coerced_to_one() {
1237        let r = PageRequest::new(0, 10);
1238        assert_eq!(r.page(), 1);
1239        assert_eq!(r.offset(), 0);
1240    }
1241
1242    #[test]
1243    fn size_is_clamped_to_max() {
1244        let r = PageRequest::new(1, 9_999);
1245        assert_eq!(r.size(), MAX_PAGE_SIZE);
1246        assert_eq!(r.limit(), i64::from(MAX_PAGE_SIZE));
1247
1248        let exact = PageRequest::new(1, MAX_PAGE_SIZE);
1249        assert_eq!(exact.size(), MAX_PAGE_SIZE);
1250
1251        let over = PageRequest::new(1, MAX_PAGE_SIZE + 1);
1252        assert_eq!(over.size(), MAX_PAGE_SIZE);
1253
1254        let under = PageRequest::new(1, MAX_PAGE_SIZE - 1);
1255        assert_eq!(under.size(), MAX_PAGE_SIZE - 1);
1256    }
1257
1258    #[test]
1259    fn size_zero_falls_back_to_default() {
1260        let r = PageRequest::new(3, 0);
1261        assert_eq!(r.size(), DEFAULT_PAGE_SIZE);
1262    }
1263
1264    #[test]
1265    fn offset_matches_page_and_size() {
1266        let r = PageRequest::new(3, 25);
1267        assert_eq!(r.offset(), 50);
1268        assert_eq!(r.limit(), 25);
1269    }
1270
1271    // ── Page metadata ──────────────────────────────────────────
1272
1273    #[test]
1274    fn empty_page_has_one_total_page() {
1275        let req = PageRequest::new(5, 50);
1276        let page: Page<i32> = Page::empty(&req);
1277        assert_eq!(page.page, 5);
1278        assert_eq!(page.size, 50);
1279        assert_eq!(page.total_elements, 0);
1280        assert_eq!(page.total_pages, 1);
1281        assert!(!page.has_next);
1282        assert!(page.has_previous);
1283        assert!(page.content.is_empty());
1284    }
1285
1286    #[test]
1287    fn metadata_reflects_middle_page() {
1288        let req = PageRequest::new(3, 20);
1289        let page = Page::new(vec![1_i32; 20], 137, &req);
1290        assert_eq!(page.page, 3);
1291        assert_eq!(page.size, 20);
1292        assert_eq!(page.total_elements, 137);
1293        assert_eq!(page.total_pages, 7); // ceil(137/20) == 7
1294        assert!(page.has_next);
1295        assert!(page.has_previous);
1296    }
1297
1298    #[test]
1299    fn metadata_reflects_last_page() {
1300        let req = PageRequest::new(7, 20);
1301        let page = Page::new(vec![1_i32; 17], 137, &req);
1302        assert_eq!(page.total_pages, 7);
1303        assert!(!page.has_next);
1304        assert!(page.has_previous);
1305    }
1306
1307    #[test]
1308    fn negative_total_is_treated_as_zero() {
1309        let page: Page<i32> = Page::new(vec![], -1, &PageRequest::default());
1310        assert_eq!(page.total_elements, 0);
1311        assert_eq!(page.total_pages, 1);
1312    }
1313
1314    #[test]
1315    fn map_preserves_metadata() {
1316        let req = PageRequest::new(2, 10);
1317        let page = Page::new(vec![1_i32, 2, 3], 25, &req);
1318        let mapped = page.map(|n| n.to_string());
1319        assert_eq!(mapped.page, 2);
1320        assert_eq!(mapped.size, 10);
1321        assert_eq!(mapped.total_elements, 25);
1322        assert_eq!(mapped.total_pages, 3);
1323        assert!(mapped.has_next);
1324        assert!(mapped.has_previous);
1325        assert_eq!(mapped.content, vec!["1", "2", "3"]);
1326    }
1327
1328    // ── JSON serialization ─────────────────────────────────────
1329
1330    #[test]
1331    fn page_serializes_to_expected_shape() {
1332        let req = PageRequest::new(2, 10);
1333        let page = Page::new(vec!["a", "b"], 25, &req);
1334        let json = serde_json::to_value(&page).unwrap();
1335        assert_eq!(json["page"], 2);
1336        assert_eq!(json["size"], 10);
1337        assert_eq!(json["total_elements"], 25);
1338        assert_eq!(json["total_pages"], 3);
1339        assert_eq!(json["has_next"], true);
1340        assert_eq!(json["has_previous"], true);
1341        assert_eq!(json["content"], serde_json::json!(["a", "b"]));
1342    }
1343
1344    // ── Extractor tests ────────────────────────────────────────
1345
1346    async fn echo(page: PageRequest) -> String {
1347        format!("{}:{}:{}", page.page(), page.size(), page.offset())
1348    }
1349
1350    async fn fetch(uri: &str) -> (StatusCode, String) {
1351        let app = Router::new().route("/items", get(echo));
1352        let res = app
1353            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1354            .await
1355            .unwrap();
1356        let status = res.status();
1357        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
1358            .await
1359            .unwrap();
1360        (status, String::from_utf8(bytes.to_vec()).unwrap())
1361    }
1362
1363    #[tokio::test]
1364    async fn extractor_uses_defaults_when_query_missing() {
1365        let (status, body) = fetch("/items").await;
1366        assert_eq!(status, StatusCode::OK);
1367        assert_eq!(body, format!("1:{DEFAULT_PAGE_SIZE}:0"));
1368    }
1369
1370    #[tokio::test]
1371    async fn extractor_parses_page_and_size() {
1372        let (status, body) = fetch("/items?page=4&size=25").await;
1373        assert_eq!(status, StatusCode::OK);
1374        assert_eq!(body, "4:25:75");
1375    }
1376
1377    #[tokio::test]
1378    async fn extractor_clamps_size_over_max() {
1379        let (status, body) = fetch("/items?page=1&size=5000").await;
1380        assert_eq!(status, StatusCode::OK);
1381        assert_eq!(body, format!("1:{MAX_PAGE_SIZE}:0"));
1382    }
1383
1384    #[tokio::test]
1385    async fn extractor_coerces_page_zero_to_one() {
1386        let (status, body) = fetch("/items?page=0&size=10").await;
1387        assert_eq!(status, StatusCode::OK);
1388        assert_eq!(body, "1:10:0");
1389    }
1390
1391    // ── Malformed input handling ───────────────────────────────
1392    //
1393    // A list endpoint should never 400 because of a malformed pager.
1394    // These cases used to reject through `Query::from_request_parts` —
1395    // they now fall back to defaults.
1396
1397    #[tokio::test]
1398    async fn extractor_ignores_non_numeric_page() {
1399        let (status, body) = fetch("/items?page=abc&size=10").await;
1400        assert_eq!(status, StatusCode::OK);
1401        assert_eq!(body, "1:10:0");
1402    }
1403
1404    #[tokio::test]
1405    async fn extractor_ignores_empty_size() {
1406        let (status, body) = fetch("/items?page=2&size=").await;
1407        assert_eq!(status, StatusCode::OK);
1408        assert_eq!(body, format!("2:{DEFAULT_PAGE_SIZE}:{DEFAULT_PAGE_SIZE}"));
1409    }
1410
1411    #[tokio::test]
1412    async fn extractor_uses_last_value_on_duplicate_keys() {
1413        let (status, body) = fetch("/items?page=1&page=4&size=5").await;
1414        assert_eq!(status, StatusCode::OK);
1415        assert_eq!(body, "4:5:15");
1416    }
1417
1418    #[tokio::test]
1419    async fn extractor_ignores_unknown_keys() {
1420        let (status, body) = fetch("/items?sort=name&page=2&size=10").await;
1421        assert_eq!(status, StatusCode::OK);
1422        assert_eq!(body, "2:10:10");
1423    }
1424
1425    #[tokio::test]
1426    async fn extractor_handles_percent_encoded_values() {
1427        // `%32` decodes to `2`
1428        let (status, body) = fetch("/items?page=%32&size=10").await;
1429        assert_eq!(status, StatusCode::OK);
1430        assert_eq!(body, "2:10:10");
1431    }
1432
1433    #[tokio::test]
1434    async fn extractor_handles_negative_page_value() {
1435        // `-1` is not a valid u32 — fall back to the default page.
1436        let (status, body) = fetch("/items?page=-1&size=10").await;
1437        assert_eq!(status, StatusCode::OK);
1438        assert_eq!(body, "1:10:0");
1439    }
1440
1441    // ── ListQuery parsing / coercion ───────────────────────────
1442
1443    #[test]
1444    fn sort_dir_from_param_defaults_to_asc() {
1445        assert_eq!(SortDir::from_param("desc"), SortDir::Desc);
1446        assert_eq!(SortDir::from_param("DESC"), SortDir::Desc);
1447        assert_eq!(SortDir::from_param("asc"), SortDir::Asc);
1448        // Anything unrecognized falls back to Asc — never an error.
1449        assert_eq!(SortDir::from_param(""), SortDir::Asc);
1450        assert_eq!(SortDir::from_param("descending"), SortDir::Asc);
1451        assert_eq!(SortDir::from_param("id;DROP TABLE"), SortDir::Asc);
1452    }
1453
1454    #[test]
1455    fn list_query_default_is_empty() {
1456        let q = ListQuery::default();
1457        assert!(q.sort().is_none());
1458        assert_eq!(q.direction(), SortDir::Asc);
1459        assert_eq!(q.filters().count(), 0);
1460        assert!(q.is_empty());
1461    }
1462
1463    #[test]
1464    fn list_query_parses_sort_dir_and_filters() {
1465        let q = parse_list_query("sort=title&dir=desc&filter[published]=true&filter[author]=ada");
1466        assert_eq!(q.sort(), Some("title"));
1467        assert_eq!(q.direction(), SortDir::Desc);
1468        let filters: Vec<(&str, &str)> = q.filters().collect();
1469        assert_eq!(filters, vec![("published", "true"), ("author", "ada")]);
1470        assert!(!q.is_empty());
1471    }
1472
1473    #[test]
1474    fn list_query_ignores_pagination_and_unknown_keys() {
1475        // page/size belong to PageRequest; unknown keys are dropped.
1476        let q = parse_list_query("page=3&size=10&whatever=x&sort=name");
1477        assert_eq!(q.sort(), Some("name"));
1478        assert_eq!(q.filters().count(), 0);
1479    }
1480
1481    #[test]
1482    fn list_query_empty_sort_and_bad_dir_fall_back() {
1483        // Empty sort → None (default order); unrecognized dir → Asc.
1484        let q = parse_list_query("sort=&dir=sideways");
1485        assert!(q.sort().is_none());
1486        assert_eq!(q.direction(), SortDir::Asc);
1487    }
1488
1489    #[test]
1490    fn list_query_drops_empty_filter_column() {
1491        let q = parse_list_query("filter[]=orphan&filter[ok]=1");
1492        let filters: Vec<(&str, &str)> = q.filters().collect();
1493        assert_eq!(filters, vec![("ok", "1")]);
1494    }
1495
1496    #[test]
1497    fn list_query_last_sort_wins() {
1498        let q = parse_list_query("sort=a&sort=b");
1499        assert_eq!(q.sort(), Some("b"));
1500    }
1501
1502    #[test]
1503    fn list_query_carries_injection_payload_verbatim() {
1504        // The extractor does NOT sanitize — it faithfully carries the raw
1505        // request. The *allowlist* in the generated `list()` is what makes
1506        // this inert (the key is not a column, so it never reaches SQL).
1507        let q = parse_list_query("sort=id;DROP%20TABLE%20users");
1508        assert_eq!(q.sort(), Some("id;DROP TABLE users"));
1509    }
1510
1511    async fn list_echo(list: ListQuery) -> String {
1512        let filters: Vec<String> = list.filters().map(|(k, v)| format!("{k}={v}")).collect();
1513        format!(
1514            "{}:{}:{}",
1515            list.sort().unwrap_or("<none>"),
1516            list.direction().param_value(),
1517            filters.join(",")
1518        )
1519    }
1520
1521    async fn fetch_list(uri: &str) -> (StatusCode, String) {
1522        let app = Router::new().route("/items", get(list_echo));
1523        let res = app
1524            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1525            .await
1526            .unwrap();
1527        let status = res.status();
1528        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
1529            .await
1530            .unwrap();
1531        (status, String::from_utf8(bytes.to_vec()).unwrap())
1532    }
1533
1534    #[tokio::test]
1535    async fn list_query_extractor_never_400s_on_garbage() {
1536        // Malicious/garbage input is carried verbatim, request still 200s.
1537        let (status, body) = fetch_list("/items?sort=id;DROP+TABLE&dir=%00&filter[x]=1").await;
1538        assert_eq!(status, StatusCode::OK);
1539        assert_eq!(body, "id;DROP TABLE:asc:x=1");
1540    }
1541
1542    #[tokio::test]
1543    async fn list_query_extractor_defaults_when_query_missing() {
1544        let (status, body) = fetch_list("/items").await;
1545        assert_eq!(status, StatusCode::OK);
1546        assert_eq!(body, "<none>:asc:");
1547    }
1548
1549    // ── Cursor: base64url round-trip ───────────────────────────
1550
1551    #[test]
1552    fn base64url_encode_known_vectors() {
1553        // RFC 4648 §10 vectors, with `=` padding stripped (we use base64url-no-pad).
1554        assert_eq!(base64url_encode(b""), "");
1555        assert_eq!(base64url_encode(b"f"), "Zg");
1556        assert_eq!(base64url_encode(b"fo"), "Zm8");
1557        assert_eq!(base64url_encode(b"foo"), "Zm9v");
1558        assert_eq!(base64url_encode(b"foob"), "Zm9vYg");
1559        assert_eq!(base64url_encode(b"fooba"), "Zm9vYmE");
1560        assert_eq!(base64url_encode(b"foobar"), "Zm9vYmFy");
1561    }
1562
1563    #[test]
1564    fn base64url_uses_url_safe_alphabet() {
1565        // Bytes 0xFB, 0xEF would produce `+` and `/` in standard base64.
1566        // The url-safe alphabet uses `-` and `_` instead.
1567        let encoded = base64url_encode(&[0xFB, 0xEF, 0xFF]);
1568        assert!(!encoded.contains('+'));
1569        assert!(!encoded.contains('/'));
1570        assert!(encoded.contains('-') || encoded.contains('_'));
1571    }
1572
1573    #[test]
1574    fn base64url_round_trip_arbitrary_bytes() {
1575        for len in 0_u8..=32 {
1576            let input: Vec<u8> = (0..len).map(|i| i.wrapping_mul(37)).collect();
1577            let encoded = base64url_encode(&input);
1578            let decoded = base64url_decode(&encoded).unwrap();
1579            assert_eq!(decoded, input, "round-trip failed at len {len}");
1580        }
1581    }
1582
1583    #[test]
1584    fn base64url_decode_rejects_invalid_chars() {
1585        assert_eq!(base64url_decode("!!!!"), None);
1586        assert_eq!(base64url_decode("AAAA="), None); // padding not accepted
1587        assert_eq!(base64url_decode("AAA+"), None); // standard-alphabet char
1588    }
1589
1590    #[test]
1591    fn base64url_decode_rejects_one_trailing_char() {
1592        // A single base64 char carries only 6 bits — not enough for a byte.
1593        assert_eq!(base64url_decode("A"), None);
1594        assert_eq!(base64url_decode("ZmA"), Some(vec![0x66, 0x60]));
1595    }
1596
1597    // ── Cursor: encode/decode ──────────────────────────────────
1598
1599    #[derive(Serialize, Deserialize, PartialEq, Debug)]
1600    struct PostKey {
1601        created_at: String,
1602        id: i64,
1603    }
1604
1605    #[test]
1606    fn cursor_round_trip_preserves_payload() {
1607        let key = PostKey {
1608            created_at: "2026-04-27T12:00:00Z".to_string(),
1609            id: 12_345,
1610        };
1611        let token = Cursor::encode(&key).unwrap();
1612        let decoded: PostKey = Cursor::decode(&token).unwrap();
1613        assert_eq!(decoded, key);
1614    }
1615
1616    #[test]
1617    fn cursor_token_is_url_safe() {
1618        // Pick a payload that contains JSON characters which would
1619        // otherwise need percent-encoding (`{`, `}`, `:`, `"`).
1620        let key = PostKey {
1621            created_at: "2026-04-27T12:00:00Z".to_string(),
1622            id: 1,
1623        };
1624        let token = Cursor::encode(&key).unwrap();
1625        // Only chars from the base64url alphabet — no `+`, `/`, `=`, `{`, `:`.
1626        assert!(
1627            token
1628                .bytes()
1629                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
1630        );
1631    }
1632
1633    #[test]
1634    fn cursor_decode_returns_none_for_garbage() {
1635        // Not base64url at all.
1636        let decoded: Option<PostKey> = Cursor::decode("!!!not a token!!!");
1637        assert!(decoded.is_none());
1638    }
1639
1640    #[test]
1641    fn cursor_decode_returns_none_for_wrong_schema() {
1642        // Valid base64url, valid JSON, but doesn't match the target type.
1643        let other = serde_json::json!({"unrelated": "value"});
1644        let token = Cursor::encode(&other).unwrap();
1645        let decoded: Option<PostKey> = Cursor::decode(&token);
1646        assert!(decoded.is_none());
1647    }
1648
1649    // ── CursorRequest coercion ─────────────────────────────────
1650
1651    #[test]
1652    fn cursor_request_defaults_when_empty() {
1653        let r = CursorRequest::default();
1654        assert!(r.cursor().is_none());
1655        assert_eq!(r.size(), DEFAULT_PAGE_SIZE);
1656        assert_eq!(r.limit(), i64::from(DEFAULT_PAGE_SIZE));
1657        assert_eq!(r.fetch_limit(), i64::from(DEFAULT_PAGE_SIZE) + 1);
1658    }
1659
1660    #[test]
1661    fn cursor_request_clamps_size_to_max() {
1662        let r = CursorRequest::new(None, 9_999);
1663        assert_eq!(r.size(), MAX_PAGE_SIZE);
1664        assert_eq!(r.fetch_limit(), i64::from(MAX_PAGE_SIZE) + 1);
1665
1666        let exact = CursorRequest::new(None, MAX_PAGE_SIZE);
1667        assert_eq!(exact.size(), MAX_PAGE_SIZE);
1668
1669        let over = CursorRequest::new(None, MAX_PAGE_SIZE + 1);
1670        assert_eq!(over.size(), MAX_PAGE_SIZE);
1671
1672        let under = CursorRequest::new(None, MAX_PAGE_SIZE - 1);
1673        assert_eq!(under.size(), MAX_PAGE_SIZE - 1);
1674    }
1675
1676    #[test]
1677    fn cursor_request_zero_size_falls_back_to_default() {
1678        let r = CursorRequest::new(None, 0);
1679        assert_eq!(r.size(), DEFAULT_PAGE_SIZE);
1680    }
1681
1682    #[test]
1683    fn cursor_request_decode_helper_returns_none_when_missing() {
1684        let r = CursorRequest::default();
1685        let decoded: Option<PostKey> = r.decode();
1686        assert!(decoded.is_none());
1687    }
1688
1689    #[test]
1690    fn cursor_request_decode_helper_round_trips() {
1691        let key = PostKey {
1692            created_at: "2026-04-27T00:00:00Z".to_string(),
1693            id: 7,
1694        };
1695        let token = Cursor::encode(&key).unwrap();
1696        let r = CursorRequest::new(Some(token), 10);
1697        let decoded: PostKey = r.decode().unwrap();
1698        assert_eq!(decoded, key);
1699    }
1700
1701    // ── CursorPage from_overfetched ────────────────────────────
1702
1703    #[test]
1704    fn cursor_page_signals_no_next_when_under_limit() {
1705        let req = CursorRequest::new(None, 5);
1706        let items = vec![1_i32, 2, 3]; // fewer than size
1707        let page = CursorPage::from_overfetched(items, &req, |&n| serde_json::json!({"id": n}));
1708        assert_eq!(page.content, vec![1, 2, 3]);
1709        assert!(!page.has_next);
1710        assert!(page.next_cursor.is_none());
1711        assert_eq!(page.size, 5);
1712    }
1713
1714    #[test]
1715    fn cursor_page_signals_no_next_at_exact_limit() {
1716        let req = CursorRequest::new(None, 3);
1717        // Caller fetched limit+1 = 4, but only got 3 — last page.
1718        let items = vec![1_i32, 2, 3];
1719        let page = CursorPage::from_overfetched(items, &req, |&n| serde_json::json!({"id": n}));
1720        assert_eq!(page.content.len(), 3);
1721        assert!(!page.has_next);
1722        assert!(page.next_cursor.is_none());
1723    }
1724
1725    #[test]
1726    fn cursor_page_truncates_overflow_and_emits_next_cursor() {
1727        let req = CursorRequest::new(None, 3);
1728        // Caller fetched limit+1 = 4 rows.
1729        let items = vec![1_i32, 2, 3, 4];
1730        let page = CursorPage::from_overfetched(items, &req, |&n| serde_json::json!({"id": n}));
1731        // Only `size` items kept.
1732        assert_eq!(page.content, vec![1, 2, 3]);
1733        assert!(page.has_next);
1734        let token = page.next_cursor.as_ref().expect("next cursor present");
1735        // Cursor encodes the *last kept* row (id=3), not the popped row (id=4).
1736        // This matters: the next page filters with a strict inequality
1737        // against this id, which keeps zero-duplicate behaviour even
1738        // if a new row gets inserted between page boundaries.
1739        let decoded: serde_json::Value = Cursor::decode(token).unwrap();
1740        assert_eq!(decoded, serde_json::json!({"id": 3}));
1741    }
1742
1743    #[test]
1744    fn cursor_page_from_overfetched_handles_encoding_failure() {
1745        let req = CursorRequest::new(None, 2);
1746        let items = vec![1_i32, 2, 3];
1747        let page = CursorPage::from_overfetched_inner(
1748            items,
1749            &req,
1750            |&n| n,
1751            |_| None::<String>, // Force encoding to fail
1752        );
1753
1754        assert_eq!(page.content, vec![1, 2]);
1755        assert_eq!(page.size, 2);
1756        assert!(page.next_cursor.is_none());
1757        assert!(!page.has_next);
1758    }
1759
1760    #[test]
1761    fn cursor_page_empty_helper() {
1762        let req = CursorRequest::new(None, 10);
1763        let page: CursorPage<i32> = CursorPage::empty(&req);
1764        assert!(page.content.is_empty());
1765        assert_eq!(page.size, 10);
1766        assert!(!page.has_next);
1767        assert!(page.next_cursor.is_none());
1768
1769        let req_diff_size = CursorRequest::new(None, 5);
1770        let page_diff_size: CursorPage<i32> = CursorPage::empty(&req_diff_size);
1771        assert_eq!(page_diff_size.size, 5);
1772    }
1773
1774    #[test]
1775    fn cursor_page_map_preserves_metadata() {
1776        let req = CursorRequest::new(None, 2);
1777        let items = vec![1_i32, 2, 3]; // overfetch by 1
1778        let page = CursorPage::from_overfetched(items, &req, |&n| serde_json::json!({"id": n}));
1779
1780        let original_cursor = page.next_cursor.clone();
1781
1782        let mapped = page.map(|n| n.to_string());
1783        assert_eq!(mapped.content, vec!["1", "2"]);
1784        assert!(mapped.has_next);
1785        assert_eq!(mapped.next_cursor, original_cursor);
1786        assert_eq!(mapped.size, 2);
1787    }
1788
1789    #[test]
1790    fn cursor_page_serializes_to_expected_shape() {
1791        let req = CursorRequest::new(None, 2);
1792        let items = vec!["a", "b", "c"];
1793        let page = CursorPage::from_overfetched(items, &req, |s| serde_json::json!({"key": s}));
1794        let json = serde_json::to_value(&page).unwrap();
1795        assert_eq!(json["size"], 2);
1796        assert_eq!(json["has_next"], true);
1797        assert!(json["next_cursor"].is_string());
1798        assert_eq!(json["content"], serde_json::json!(["a", "b"]));
1799    }
1800
1801    #[test]
1802    fn cursor_page_last_page_serializes_null_cursor() {
1803        let req = CursorRequest::new(None, 5);
1804        let items = vec!["only"];
1805        let page = CursorPage::from_overfetched(items, &req, |s| serde_json::json!({"key": s}));
1806        let json = serde_json::to_value(&page).unwrap();
1807        assert_eq!(json["has_next"], false);
1808        assert!(json["next_cursor"].is_null());
1809    }
1810
1811    // ── CursorRequest extractor ────────────────────────────────
1812
1813    async fn cursor_echo(req: CursorRequest) -> String {
1814        format!(
1815            "{}|{}|{}",
1816            req.cursor().unwrap_or("-"),
1817            req.size(),
1818            req.fetch_limit(),
1819        )
1820    }
1821
1822    async fn fetch_cursor(uri: &str) -> (StatusCode, String) {
1823        let app = Router::new().route("/feed", get(cursor_echo));
1824        let res = app
1825            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1826            .await
1827            .unwrap();
1828        let status = res.status();
1829        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
1830            .await
1831            .unwrap();
1832        (status, String::from_utf8(bytes.to_vec()).unwrap())
1833    }
1834
1835    #[tokio::test]
1836    async fn cursor_extractor_uses_defaults_when_query_missing() {
1837        let (status, body) = fetch_cursor("/feed").await;
1838        assert_eq!(status, StatusCode::OK);
1839        assert_eq!(
1840            body,
1841            format!("-|{DEFAULT_PAGE_SIZE}|{}", DEFAULT_PAGE_SIZE + 1)
1842        );
1843    }
1844
1845    #[tokio::test]
1846    async fn cursor_extractor_parses_cursor_and_size() {
1847        let (status, body) = fetch_cursor("/feed?cursor=abc123&size=5").await;
1848        assert_eq!(status, StatusCode::OK);
1849        assert_eq!(body, "abc123|5|6");
1850    }
1851
1852    #[tokio::test]
1853    async fn cursor_extractor_clamps_size_over_max() {
1854        let (status, body) = fetch_cursor("/feed?cursor=t&size=9999").await;
1855        assert_eq!(status, StatusCode::OK);
1856        assert_eq!(body, format!("t|{MAX_PAGE_SIZE}|{}", MAX_PAGE_SIZE + 1));
1857
1858        // Exact MAX_PAGE_SIZE
1859        let (status, body) = fetch_cursor(&format!("/feed?cursor=t&size={MAX_PAGE_SIZE}")).await;
1860        assert_eq!(status, StatusCode::OK);
1861        assert_eq!(body, format!("t|{MAX_PAGE_SIZE}|{}", MAX_PAGE_SIZE + 1));
1862
1863        // MAX_PAGE_SIZE + 1
1864        let size = MAX_PAGE_SIZE + 1;
1865        let (status, body) = fetch_cursor(&format!("/feed?cursor=t&size={size}")).await;
1866        assert_eq!(status, StatusCode::OK);
1867        assert_eq!(body, format!("t|{MAX_PAGE_SIZE}|{}", MAX_PAGE_SIZE + 1));
1868    }
1869
1870    #[tokio::test]
1871    async fn cursor_extractor_ignores_empty_cursor() {
1872        // Empty `cursor=` should be treated as "no cursor", not as `Some("")`.
1873        let (status, body) = fetch_cursor("/feed?cursor=&size=10").await;
1874        assert_eq!(status, StatusCode::OK);
1875        assert_eq!(body, "-|10|11");
1876    }
1877
1878    #[tokio::test]
1879    async fn cursor_extractor_does_not_400_on_malformed_size() {
1880        let (status, body) = fetch_cursor("/feed?cursor=t&size=abc").await;
1881        assert_eq!(status, StatusCode::OK);
1882        assert_eq!(
1883            body,
1884            format!("t|{DEFAULT_PAGE_SIZE}|{}", DEFAULT_PAGE_SIZE + 1)
1885        );
1886    }
1887
1888    #[tokio::test]
1889    async fn cursor_extractor_handles_percent_encoded_token() {
1890        // base64url tokens never need percent-encoding, but a paranoid
1891        // client might do it anyway. `%2D` decodes to `-`.
1892        let (status, body) = fetch_cursor("/feed?cursor=ab%2Dcd&size=2").await;
1893        assert_eq!(status, StatusCode::OK);
1894        assert_eq!(body, "ab-cd|2|3");
1895    }
1896
1897    // ── Concurrent-insert simulation ───────────────────────────
1898    //
1899    // The story's success metric is "zero duplicate items during
1900    // concurrent inserts". This test simulates a feed where a new row
1901    // arrives between the first and second page request, and verifies
1902    // the keyset filter still returns every original row exactly once.
1903
1904    #[test]
1905    fn concurrent_inserts_do_not_cause_duplicates() {
1906        // Sort by (created_at desc, id desc) — this is the recommended
1907        // tie-breaker pattern from the docs.
1908        #[derive(Clone, Debug, PartialEq, Eq)]
1909        struct Row {
1910            id: i64,
1911            created_at: i64,
1912        }
1913        #[derive(Serialize, Deserialize)]
1914        struct Key {
1915            created_at: i64,
1916            id: i64,
1917        }
1918
1919        let mut table: Vec<Row> = (1..=5)
1920            .map(|id| Row {
1921                id,
1922                created_at: 1_000 - id, // older as id grows
1923            })
1924            .collect();
1925        // Sort newest-first so id=1 is first.
1926        table.sort_by_key(|r| std::cmp::Reverse((r.created_at, r.id)));
1927
1928        // First request: no cursor, size=2.
1929        let req1 = CursorRequest::new(None, 2);
1930        let fetch1 = usize::try_from(req1.fetch_limit()).unwrap();
1931        let fetched1: Vec<Row> = table.iter().take(fetch1).cloned().collect();
1932        let page1 = CursorPage::from_overfetched(fetched1, &req1, |r| Key {
1933            created_at: r.created_at,
1934            id: r.id,
1935        });
1936        let cursor1 = page1.next_cursor.clone().expect("page 1 has next");
1937        assert_eq!(page1.content.len(), 2);
1938
1939        // ── Concurrent insert lands BEFORE the next request ──────
1940        // A new row is inserted with the highest created_at, so it
1941        // would appear on a fresh page 1 — but our cursor pagination
1942        // is keyset-based, so the second request must skip it.
1943        table.insert(
1944            0,
1945            Row {
1946                id: 99,
1947                created_at: 9_999,
1948            },
1949        );
1950
1951        // Second request: cursor from page 1, size=2.
1952        let req2 = CursorRequest::new(Some(cursor1), 2);
1953        let key: Key = req2.decode().unwrap();
1954        let fetch2 = usize::try_from(req2.fetch_limit()).unwrap();
1955
1956        // Apply the keyset filter: rows that come *after* the cursor
1957        // in the (created_at desc, id desc) ordering, then take the
1958        // overfetch window.
1959        let fetched2: Vec<Row> = table
1960            .iter()
1961            .filter(|r| {
1962                r.created_at < key.created_at || (r.created_at == key.created_at && r.id < key.id)
1963            })
1964            .take(fetch2)
1965            .cloned()
1966            .collect();
1967        let page2 = CursorPage::from_overfetched(fetched2, &req2, |r| Key {
1968            created_at: r.created_at,
1969            id: r.id,
1970        });
1971
1972        // Combine the two pages. No row should appear twice, and the
1973        // newly-inserted id=99 must NOT show up (the user already
1974        // scrolled past where it would have appeared).
1975        let mut all: Vec<Row> = page1.content;
1976        all.extend(page2.content);
1977        let mut ids: Vec<i64> = all.iter().map(|r| r.id).collect();
1978        let original_len = ids.len();
1979        ids.sort_unstable();
1980        ids.dedup();
1981        assert_eq!(ids.len(), original_len, "no duplicates across pages");
1982        assert!(
1983            !all.iter().any(|r| r.id == 99),
1984            "concurrently-inserted row not duplicated"
1985        );
1986    }
1987
1988    // ── Signed cursors ─────────────────────────────────────────
1989
1990    const TEST_KEY: &[u8] = b"test-signing-key-do-not-use-in-prod";
1991
1992    #[derive(Serialize, Deserialize, PartialEq, Debug)]
1993    struct ScopedCursor {
1994        tenant_id: i64,
1995        cursor_id: i64,
1996    }
1997
1998    #[test]
1999    fn signed_cursor_round_trip() {
2000        let payload = ScopedCursor {
2001            tenant_id: 42,
2002            cursor_id: 7,
2003        };
2004        let token = Cursor::encode_signed(&payload, TEST_KEY).unwrap();
2005        // Token shape: <payload>.<sig>
2006        assert!(token.contains('.'));
2007        let decoded: ScopedCursor = Cursor::decode_signed(&token, TEST_KEY).unwrap();
2008        assert_eq!(decoded, payload);
2009    }
2010
2011    #[test]
2012    fn signed_cursor_rejects_tampered_payload() {
2013        let payload = ScopedCursor {
2014            tenant_id: 42,
2015            cursor_id: 7,
2016        };
2017        let token = Cursor::encode_signed(&payload, TEST_KEY).unwrap();
2018        // Forge: re-encode a payload with tenant_id=99 but reuse the
2019        // original signature.
2020        let forged_payload = ScopedCursor {
2021            tenant_id: 99,
2022            cursor_id: 7,
2023        };
2024        let forged_b64 = base64url_encode(&serde_json::to_vec(&forged_payload).unwrap());
2025        let (_, sig_b64) = token.split_once('.').unwrap();
2026        let forged_token = format!("{forged_b64}.{sig_b64}");
2027        let decoded: Option<ScopedCursor> = Cursor::decode_signed(&forged_token, TEST_KEY);
2028        assert!(decoded.is_none(), "tampered cursor must not verify");
2029    }
2030
2031    #[test]
2032    fn signed_cursor_rejects_wrong_key() {
2033        let payload = ScopedCursor {
2034            tenant_id: 42,
2035            cursor_id: 7,
2036        };
2037        let token = Cursor::encode_signed(&payload, TEST_KEY).unwrap();
2038        let decoded: Option<ScopedCursor> = Cursor::decode_signed(&token, b"different-key");
2039        assert!(decoded.is_none());
2040    }
2041
2042    #[test]
2043    fn signed_cursor_rejects_unsigned_token() {
2044        // A plain (unsigned) token should not verify against the
2045        // signed decoder — even though it's structurally valid JSON.
2046        let payload = ScopedCursor {
2047            tenant_id: 42,
2048            cursor_id: 7,
2049        };
2050        let unsigned = Cursor::encode(&payload).unwrap();
2051        let decoded: Option<ScopedCursor> = Cursor::decode_signed(&unsigned, TEST_KEY);
2052        assert!(
2053            decoded.is_none(),
2054            "unsigned token must not pass signed verification"
2055        );
2056    }
2057
2058    #[test]
2059    fn signed_cursor_rejects_missing_signature_segment() {
2060        // Token without a `.` separator.
2061        let decoded: Option<ScopedCursor> = Cursor::decode_signed("just-some-bytes", TEST_KEY);
2062        assert!(decoded.is_none());
2063    }
2064
2065    #[test]
2066    fn signed_cursor_rejects_garbage() {
2067        let decoded: Option<ScopedCursor> = Cursor::decode_signed("!!!.!!!", TEST_KEY);
2068        assert!(decoded.is_none());
2069    }
2070
2071    #[test]
2072    fn cursor_request_decode_signed_returns_none_when_missing() {
2073        let r = CursorRequest::default();
2074        let decoded: Option<ScopedCursor> = r.decode_signed(TEST_KEY);
2075        assert!(decoded.is_none());
2076    }
2077
2078    #[test]
2079    fn cursor_request_decode_signed_round_trips() {
2080        let payload = ScopedCursor {
2081            tenant_id: 42,
2082            cursor_id: 7,
2083        };
2084        let token = Cursor::encode_signed(&payload, TEST_KEY).unwrap();
2085        let r = CursorRequest::new(Some(token), 10);
2086        let decoded: ScopedCursor = r.decode_signed(TEST_KEY).unwrap();
2087        assert_eq!(decoded, payload);
2088    }
2089
2090    #[test]
2091    fn cursor_page_from_overfetched_signed_emits_signed_token() {
2092        let req = CursorRequest::new(None, 2);
2093        let items = vec![1_i32, 2, 3]; // overfetch by 1
2094        let page = CursorPage::from_overfetched_signed(items, &req, TEST_KEY, |&n| ScopedCursor {
2095            tenant_id: 42,
2096            cursor_id: i64::from(n),
2097        });
2098        assert!(page.has_next);
2099        let token = page.next_cursor.as_ref().unwrap();
2100        assert!(token.contains('.'), "signed token format is payload.sig");
2101        // Round-trip through the signed decoder.
2102        let key: ScopedCursor = Cursor::decode_signed(token, TEST_KEY).unwrap();
2103        assert_eq!(key.cursor_id, 2); // boundary = last kept item
2104        // Plain decoder must NOT happen to extract a structurally
2105        // valid value, because the token contains the signature suffix.
2106        let mishandled: Option<ScopedCursor> = Cursor::decode(token);
2107        assert!(mishandled.is_none());
2108    }
2109
2110    #[test]
2111    fn signed_cursor_signature_is_constant_time_compared() {
2112        // Smoke test: same key, same input → identical sig. Different
2113        // key → different sig. (Constant-time-ness itself is not
2114        // observable from this test; we're just exercising the path.)
2115        let p = ScopedCursor {
2116            tenant_id: 1,
2117            cursor_id: 1,
2118        };
2119        let a = Cursor::encode_signed(&p, b"k1").unwrap();
2120        let b = Cursor::encode_signed(&p, b"k1").unwrap();
2121        let c = Cursor::encode_signed(&p, b"k2").unwrap();
2122        assert_eq!(a, b);
2123        assert_ne!(a, c);
2124    }
2125
2126    #[tokio::test]
2127    async fn page_request_extractor_defaults_on_missing_uri_query() {
2128        use axum::extract::FromRequestParts;
2129        use axum::http::Request;
2130        let req = Request::builder().uri("/").body(()).unwrap();
2131        let (mut parts, ()) = req.into_parts();
2132
2133        let extracted = PageRequest::from_request_parts(&mut parts, &())
2134            .await
2135            .unwrap();
2136
2137        assert_eq!(extracted.page(), 1);
2138        assert_eq!(extracted.size(), 20); // Default is 20
2139    }
2140
2141    #[tokio::test]
2142    async fn cursor_extractor_defaults_on_missing_uri_query() {
2143        use axum::extract::FromRequestParts;
2144        use axum::http::Request;
2145        let req = Request::builder().uri("/").body(()).unwrap();
2146        let (mut parts, ()) = req.into_parts();
2147
2148        let extracted = CursorRequest::from_request_parts(&mut parts, &())
2149            .await
2150            .unwrap();
2151
2152        assert_eq!(extracted.size(), 20); // Default is 20
2153        assert!(extracted.cursor.is_none());
2154    }
2155
2156    #[test]
2157    fn base64url_encode_pad_branch() {
2158        // 2 byte string leads to rem.len() == 2, which exercises the second padding path.
2159        let encoded = base64url_encode(b"ab");
2160        assert_eq!(encoded, "YWI");
2161    }
2162
2163    #[test]
2164    fn base64url_decode_pad_branch() {
2165        // Try to decode exactly a length that produces rem 3.
2166        // YWI decodes to 'ab' (2 bytes)
2167        let decoded = base64url_decode("YWI").unwrap();
2168        assert_eq!(decoded, b"ab");
2169    }
2170
2171    #[test]
2172    fn cursor_encode_fails_gracefully_on_serialization_error() {
2173        use serde::Serialize;
2174
2175        struct FailToSerialize;
2176
2177        impl Serialize for FailToSerialize {
2178            fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
2179            where
2180                S: serde::Serializer,
2181            {
2182                Err(serde::ser::Error::custom("forced failure"))
2183            }
2184        }
2185
2186        let res = Cursor::encode(&FailToSerialize);
2187        assert!(res.is_err());
2188
2189        let res_signed = Cursor::encode_signed(&FailToSerialize, b"key");
2190        assert!(res_signed.is_err());
2191    }
2192
2193    // ── IntoResponse + RFC 8288 Link headers ────────────────────
2194
2195    #[tokio::test]
2196    async fn page_into_response_emits_envelope_and_link_headers() {
2197        async fn handler() -> Page<u32> {
2198            // Page 2 of 7 (137 items, size 20): prev + next both exist.
2199            Page::new((21..=40).collect(), 137, &PageRequest::new(2, 20))
2200        }
2201        let app = Router::new().route("/items", get(handler));
2202        let res = app
2203            .oneshot(
2204                Request::builder()
2205                    .uri("/items")
2206                    .body(Body::empty())
2207                    .unwrap(),
2208            )
2209            .await
2210            .unwrap();
2211
2212        assert_eq!(res.status(), StatusCode::OK);
2213        let link = res
2214            .headers()
2215            .get(header::LINK)
2216            .expect("Link header must be present")
2217            .to_str()
2218            .unwrap()
2219            .to_owned();
2220        assert!(link.contains("<?page=1&size=20>; rel=\"first\""), "{link}");
2221        assert!(
2222            link.contains("<?page=1&size=20>; rel=\"prev\""),
2223            "prev must point to page 1: {link}"
2224        );
2225        assert!(link.contains("<?page=3&size=20>; rel=\"next\""), "{link}");
2226        assert!(link.contains("<?page=7&size=20>; rel=\"last\""), "{link}");
2227
2228        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
2229            .await
2230            .unwrap();
2231        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2232        assert_eq!(json["page"], 2);
2233        assert_eq!(json["size"], 20);
2234        assert_eq!(json["total_elements"], 137);
2235        assert_eq!(json["total_pages"], 7);
2236        assert_eq!(json["content"].as_array().unwrap().len(), 20);
2237    }
2238
2239    #[tokio::test]
2240    async fn first_page_has_no_prev_link() {
2241        async fn handler() -> Page<u32> {
2242            Page::new((1..=20).collect(), 40, &PageRequest::new(1, 20))
2243        }
2244        let app = Router::new().route("/items", get(handler));
2245        let res = app
2246            .oneshot(
2247                Request::builder()
2248                    .uri("/items")
2249                    .body(Body::empty())
2250                    .unwrap(),
2251            )
2252            .await
2253            .unwrap();
2254        let link = res
2255            .headers()
2256            .get(header::LINK)
2257            .unwrap()
2258            .to_str()
2259            .unwrap()
2260            .to_owned();
2261        assert!(
2262            !link.contains("rel=\"prev\""),
2263            "page 1 must not advertise prev: {link}"
2264        );
2265        assert!(link.contains("rel=\"next\""), "{link}");
2266        assert!(link.contains("rel=\"first\""), "{link}");
2267        assert!(link.contains("rel=\"last\""), "{link}");
2268    }
2269
2270    #[test]
2271    fn empty_page_last_link_clamps_to_one() {
2272        // An empty collection has `total_pages == 0`. The `last` link must
2273        // still target a valid 1-indexed page (`page=1`), never `page=0`.
2274        let link = page_link_header_value(1, 20, 0, false);
2275        assert!(
2276            link.contains("<?page=1&size=20>; rel=\"last\""),
2277            "last link must clamp to page 1 for an empty collection: {link}"
2278        );
2279        assert!(
2280            !link.contains("page=0"),
2281            "no link may reference the invalid page 0: {link}"
2282        );
2283    }
2284
2285    #[tokio::test]
2286    async fn cursor_page_emits_next_link_only() {
2287        async fn handler() -> CursorPage<u32> {
2288            // Over-fetch: request size 2, 3 rows returned → has_next, next_cursor set.
2289            CursorPage::from_overfetched(vec![1, 2, 3], &CursorRequest::new(None, 2), |&n| n)
2290        }
2291        let app = Router::new().route("/feed", get(handler));
2292        let res = app
2293            .oneshot(Request::builder().uri("/feed").body(Body::empty()).unwrap())
2294            .await
2295            .unwrap();
2296        let link = res
2297            .headers()
2298            .get(header::LINK)
2299            .expect("cursor page with a next page must emit a Link")
2300            .to_str()
2301            .unwrap()
2302            .to_owned();
2303        assert!(link.contains("rel=\"next\""), "{link}");
2304        assert!(
2305            link.contains("cursor="),
2306            "next link must carry the cursor token: {link}"
2307        );
2308        assert!(
2309            !link.contains("rel=\"prev\""),
2310            "keyset pagination has no prev: {link}"
2311        );
2312    }
2313
2314    #[tokio::test]
2315    async fn cursor_page_last_page_has_no_link() {
2316        async fn handler() -> CursorPage<u32> {
2317            // Fewer rows than the page size → last page, no next cursor.
2318            CursorPage::from_overfetched(vec![1, 2], &CursorRequest::new(None, 5), |&n| n)
2319        }
2320        let app = Router::new().route("/feed", get(handler));
2321        let res = app
2322            .oneshot(Request::builder().uri("/feed").body(Body::empty()).unwrap())
2323            .await
2324            .unwrap();
2325        assert!(
2326            res.headers().get(header::LINK).is_none(),
2327            "last cursor page must not advertise a next link"
2328        );
2329    }
2330
2331    #[test]
2332    fn paginate_in_memory_windows_and_counts() {
2333        let all: Vec<u32> = (1..=25).collect();
2334        let page = Page::paginate_in_memory(all, &PageRequest::new(2, 10));
2335        assert_eq!(page.page, 2);
2336        assert_eq!(page.size, 10);
2337        assert_eq!(page.total_elements, 25);
2338        assert_eq!(page.total_pages, 3);
2339        assert_eq!(page.content, (11..=20).collect::<Vec<_>>());
2340        assert!(page.has_next);
2341        assert!(page.has_previous);
2342    }
2343}