Skip to main content

cratestack_core/
page.rs

1//! Generic paginated-page envelope used by every `list` route. The shape
2//! mirrors what generated clients consume.
3
4use serde::{Deserialize, Serialize};
5
6/// Hard ceiling on the `limit` query parameter (REST) / RPC list-input
7/// field every generated list route accepts, regardless of whether the
8/// model is `@@paged`. Requests above this are rejected with a `400`,
9/// the same way negative `limit`/`offset` already are — see
10/// `handle_list_<plural>_dispatch` in the generated code, shared
11/// byte-for-byte between REST and RPC dispatch.
12///
13/// Without this, a caller can request an arbitrarily large `limit` and
14/// force the generated handler to fetch (and, for `@@paged` models,
15/// separately COUNT) an unbounded number of rows in one request — a
16/// resource-exhaustion vector with no framework-level mitigation.
17/// Chosen as a generous-but-real ceiling rather than a small one: it
18/// should never trip on realistic paginated-UI or batch-export usage,
19/// only on pathological/abusive requests.
20pub const MAX_LIST_LIMIT: i64 = 1000;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
23#[serde(rename_all = "camelCase")]
24pub struct PageInfo {
25    pub limit: Option<i64>,
26    pub offset: Option<i64>,
27    pub has_next_page: bool,
28    pub has_previous_page: bool,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct Page<T> {
34    pub items: Vec<T>,
35    pub total_count: Option<i64>,
36    pub page_info: PageInfo,
37}
38
39impl<T> Page<T> {
40    pub fn new(items: Vec<T>, page_info: PageInfo) -> Self {
41        Self {
42            items,
43            total_count: None,
44            page_info,
45        }
46    }
47
48    pub fn with_total_count(mut self, total_count: Option<i64>) -> Self {
49        self.total_count = total_count;
50        self
51    }
52}
53
54/// Built-in pagination-input argument type (`PageInput` in `.cstack`),
55/// currently valid only as a procedure argument — the request-side mirror
56/// of [`Page`]/[`PageInfo`] on the response side. Field names and
57/// optionality match `PageInfo`'s own `limit`/`offset` exactly, so a
58/// generated `list` route and a hand-written `PageInput`-accepting
59/// procedure decode the same wire shape.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
61#[serde(rename_all = "camelCase")]
62pub struct PageInput {
63    pub limit: Option<i64>,
64    pub offset: Option<i64>,
65}
66
67impl PageInput {
68    /// Resolves `limit`/`offset` into concrete, safe values: `limit`
69    /// defaults to `max_limit` when unset and is clamped to `[0,
70    /// max_limit]`; `offset` defaults to `0` and is clamped to `>= 0`.
71    /// Mirrors the same rule generated `list` routes already apply to
72    /// their own `limit`/`offset` input — see [`MAX_LIST_LIMIT`] — so a
73    /// procedure using `PageInput` gets the identical resource-exhaustion
74    /// guard for free instead of reimplementing it by hand.
75    pub fn resolve(&self, max_limit: i64) -> (i64, i64) {
76        let limit = self.limit.unwrap_or(max_limit).clamp(0, max_limit);
77        let offset = self.offset.unwrap_or(0).max(0);
78        (limit, offset)
79    }
80}