Skip to main content

acme_proxy_admin/webadmin/handlers/
paging.rs

1//! The shared `?limit=&offset=` window and the envelope every list returns.
2//!
3//! A window is clamped rather than refused: `limit` to `1..=admin.page_size_max`
4//! and `offset` to `0..=MAX_OFFSET`, which keeps `offset + limit` from
5//! overflowing. The envelope, `{items, total, limit, offset}`, is the shape the
6//! CLI's `--json` answers too.
7
8use serde::Deserialize;
9use serde_json::{Value, json};
10
11use acme_proxy_core::config::Config;
12
13/// Rows per page when the caller does not say.
14const DEFAULT_PAGE_SIZE: i64 = 50;
15
16/// The furthest a caller may page in. Far beyond any real table, and low
17/// enough that `offset + limit` cannot overflow — see [`PageParams::resolve`].
18const MAX_OFFSET: i64 = i64::MAX / 2;
19
20/// The page window a list endpoint accepts.
21#[derive(Debug, Deserialize, Default)]
22pub struct PageParams {
23    pub limit: Option<i64>,
24    pub offset: Option<i64>,
25}
26
27/// A resolved, clamped window.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct Page {
30    pub limit: i64,
31    pub offset: i64,
32}
33
34impl PageParams {
35    /// Builds a window from fields a caller declared inline.
36    #[must_use]
37    pub fn from(limit: Option<i64>, offset: Option<i64>) -> Self {
38        Self { limit, offset }
39    }
40
41    /// Clamps the caller's window into something the database can be handed.
42    ///
43    /// Clamped rather than refused: a `?limit=100000` is far more likely to be
44    /// a client that does not know the ceiling than an attack, and answering
45    /// with the largest allowed page is more useful than a 400. A negative
46    /// offset is nonsense, so it becomes 0 rather than a SQL error.
47    ///
48    /// The *upper* clamp is not cosmetic: `pages::pager` computes
49    /// `offset + limit` to place the "next" link, and `?offset=` arrives
50    /// straight off the query string as an `i64`. At `i64::MAX` that addition
51    /// overflows — a panic in a debug build, a silent wrap to a negative page
52    /// window in release, which sets no `overflow-checks`. [`MAX_OFFSET`]
53    /// leaves headroom for the addition no matter what `limit` resolved to.
54    #[must_use]
55    pub fn resolve(&self, config: &Config) -> Page {
56        let max = config.admin.page_size_max.max(1);
57        Page {
58            limit: self.limit.unwrap_or(DEFAULT_PAGE_SIZE).clamp(1, max),
59            offset: self.offset.unwrap_or(0).clamp(0, MAX_OFFSET),
60        }
61    }
62}
63
64/// The envelope every list endpoint returns.
65///
66/// `total` is the count the same filters match *unpaged*, which is what a page
67/// control needs and what a bare array cannot express.
68#[must_use]
69pub fn page_envelope(items: Vec<Value>, total: i64, page: Page) -> Value {
70    json!({
71        "items": items,
72        "total": total,
73        "limit": page.limit,
74        "offset": page.offset,
75    })
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    fn config_with_max(max: i64) -> Config {
83        let mut config = Config::default();
84        config.admin.page_size_max = max;
85        config
86    }
87
88    #[test]
89    fn an_absent_window_is_the_default_page() {
90        let page = PageParams::default().resolve(&Config::default());
91        assert_eq!(
92            page,
93            Page {
94                limit: 50,
95                offset: 0
96            }
97        );
98    }
99
100    #[test]
101    fn a_limit_is_clamped_into_range_rather_than_refused() {
102        let config = config_with_max(200);
103        let cases = [
104            (Some(10), 10),
105            (Some(200), 200),
106            // Over the ceiling: answered with the largest allowed page.
107            (Some(100_000), 200),
108            // Nonsense: one row, not zero and not a SQL error.
109            (Some(0), 1),
110            (Some(-5), 1),
111        ];
112        for (requested, expected) in cases {
113            let page = PageParams {
114                limit: requested,
115                offset: None,
116            }
117            .resolve(&config);
118            assert_eq!(page.limit, expected, "limit={requested:?}");
119        }
120    }
121
122    #[test]
123    fn a_negative_offset_becomes_zero() {
124        let page = PageParams {
125            limit: None,
126            offset: Some(-7),
127        }
128        .resolve(&Config::default());
129        assert_eq!(page.offset, 0);
130    }
131
132    /// `?offset=9223372036854775807` reaches `pager`, which computes
133    /// `offset + limit`. Unclamped that overflows: a panic in debug, a wrapped
134    /// negative window in release, which sets no `overflow-checks`.
135    #[test]
136    fn an_offset_at_the_top_of_the_range_is_clamped_below_an_overflow() {
137        let page = PageParams {
138            limit: None,
139            offset: Some(i64::MAX),
140        }
141        .resolve(&Config::default());
142        assert_eq!(page.offset, MAX_OFFSET);
143        assert!(page.offset.checked_add(page.limit).is_some());
144    }
145
146    /// A misconfigured ceiling must not make every page empty.
147    #[test]
148    fn a_page_size_max_of_zero_still_yields_a_usable_page() {
149        let page = PageParams {
150            limit: Some(50),
151            offset: None,
152        }
153        .resolve(&config_with_max(0));
154        assert_eq!(page.limit, 1);
155    }
156
157    #[test]
158    fn the_envelope_reports_the_window_it_was_given() {
159        let page = Page {
160            limit: 2,
161            offset: 4,
162        };
163        let envelope = page_envelope(vec![json!({"id": "a"})], 17, page);
164        assert_eq!(envelope["total"], 17);
165        assert_eq!(envelope["limit"], 2);
166        assert_eq!(envelope["offset"], 4);
167        assert_eq!(envelope["items"].as_array().unwrap().len(), 1);
168    }
169}