Skip to main content

siderite_core/pagination/
cursor.rs

1//! Cursor pagination over HTTP: [`CursorParams`] reads and checks
2//! `?after=TOKEN&limit=N` (or `?before=TOKEN&limit=N`), and
3//! [`CursorPage`] is the response body.
4//!
5//! [`CursorPage`] is built from the ORM's
6//! [`orm::CursorPage`](siderite_orm::CursorPage): one statement per page
7//! and no `total`. A token the server rejects (another list's, a changed
8//! filter, a mangled one) answers `400` with the problem `code`
9//! `cursor.E001` to `cursor.E004`; clients restart from the first page.
10//! The client repeats its query parameters with each request, and the
11//! handler rebuilds the same queryset, because the token does not carry
12//! the filters.
13
14use serde::ser::SerializeStruct;
15use serde::{Deserialize, Serialize, Serializer};
16use serde_json::{Number, Value, json};
17use siderite_orm::{Model, OrmError, QuerySet};
18use siderite_validation::dump::DumpSerialize;
19use siderite_validation::model::{Constraint, check};
20use siderite_validation::{Dump, DumpError, DumpOptions, Schema};
21use siderite_validation::{SchemaObject, SchemaRegistry};
22use siderite_validation::{Validate, ValidationContext};
23
24/// The result of [`CursorParams::fetch`].
25pub type Fetched<M> = Result<CursorPage<M>, OrmError>;
26
27/// `?after=TOKEN` or `?before=TOKEN`, and `?limit=N` from 1 to `MAX`,
28/// `DEFAULT` when absent.
29///
30/// Without a token the first page is returned. Both tokens together, an
31/// out-of-range or non-numeric limit are rejected with 422 before the
32/// handler runs, and the documented query parameters state the bounds.
33/// Use it as `Query<CursorParams>` (at most 100, default 20) or choose
34/// the bounds: `Query<CursorParams<500, 50>>`.
35#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
36pub struct CursorParams<const MAX: u64 = 100, const DEFAULT: u64 = 20> {
37    /// The `next` cursor of the previous page: continue after it.
38    #[serde(default)]
39    pub after: Option<String>,
40    /// The `previous` cursor of a page: the page before it.
41    #[serde(default)]
42    pub before: Option<String>,
43    /// Items per page.
44    #[serde(default)]
45    pub limit: Option<u64>,
46}
47
48impl<const MAX: u64, const DEFAULT: u64> CursorParams<MAX, DEFAULT> {
49    /// Checked once per instantiation, at compile time.
50    const BOUNDS: () = assert!(
51        1 <= DEFAULT && DEFAULT <= MAX,
52        "CursorParams needs 1 <= DEFAULT <= MAX"
53    );
54
55    /// The requested page size, `DEFAULT` when absent.
56    pub fn limit(&self) -> u64 {
57        let () = Self::BOUNDS;
58        self.limit.unwrap_or(DEFAULT)
59    }
60
61    /// Fetch this page of `queryset` (one statement, no count).
62    ///
63    /// # Errors
64    /// [`OrmError::Cursor`] for a token the queryset rejects (answered
65    /// with 400 by `ApiError`), capability or backend errors.
66    pub async fn fetch<M: Model>(&self, queryset: QuerySet<M>) -> Fetched<M> {
67        let mut queryset = queryset;
68        if let Some(token) = &self.after {
69            queryset = queryset.after(token);
70        }
71        if let Some(token) = &self.before {
72            queryset = queryset.before(token);
73        }
74        let page = queryset.cursor_page(self.limit()).await?;
75        Ok(page.into())
76    }
77}
78
79impl<const MAX: u64, const D: u64> Validate for CursorParams<MAX, D> {
80    fn prepare(input: &mut Value, ctx: &mut ValidationContext) {
81        let Value::Object(fields) = input else {
82            return;
83        };
84        for key in ["after", "before"] {
85            if let Some(value) = fields.get_mut(key) {
86                ctx.at(key, |ctx| Option::<String>::prepare(value, ctx));
87            }
88        }
89        let given = |key| fields.get(key).is_some_and(|v| !v.is_null());
90        if given("after") && given("before") {
91            ctx.at("before", |ctx| {
92                ctx.error("exclusive", "cannot be combined with `after`");
93            });
94        }
95        let Some(value) = fields.get_mut("limit") else {
96            return;
97        };
98        ctx.at("limit", |ctx| {
99            let before = ctx.error_count();
100            Option::<u64>::prepare(value, ctx);
101            if ctx.error_count() > before {
102                return;
103            }
104            check(value, Constraint::Ge(&Number::from(1_u64)), ctx);
105            check(value, Constraint::Le(&Number::from(MAX)), ctx);
106        });
107    }
108}
109
110impl<const MAX: u64, const D: u64> Schema for CursorParams<MAX, D> {
111    fn schema(_: &mut SchemaRegistry) -> SchemaObject {
112        let properties = json!({
113            "after": {
114                "type": "string",
115                "description": "The `next` cursor of the previous page. \
116                    Not combined with `before`.",
117            },
118            "before": {
119                "type": "string",
120                "description": "The `previous` cursor of a page, to read \
121                    the page before it. Not combined with `after`.",
122            },
123            "limit": {
124                "type": "integer",
125                "minimum": 1,
126                "maximum": MAX,
127                "default": D,
128                "description": "Items per page.",
129            },
130        });
131        SchemaObject::of_type("object").with("properties", properties)
132    }
133}
134
135/// One page of `T` as a response body, with the cursors to its
136/// neighbours and no total.
137///
138/// Its schema is inlined rather than named, so pages of different item
139/// types never collide in `components.schemas`.
140#[derive(Debug, Clone, PartialEq, Serialize)]
141pub struct CursorPage<T> {
142    /// The items of this page.
143    pub items: Vec<T>,
144    /// Cursor for the page after this one; `null` on the last page.
145    pub next: Option<String>,
146    /// Cursor for the page before this one; `null` on the first page.
147    pub previous: Option<String>,
148}
149
150impl<T> CursorPage<T> {
151    /// The same page with each item converted by `f`.
152    pub fn map<U>(self, f: impl FnMut(T) -> U) -> CursorPage<U> {
153        CursorPage {
154            items: self.items.into_iter().map(f).collect(),
155            next: self.next,
156            previous: self.previous,
157        }
158    }
159
160    /// The same page with each item converted by `f`, stopping at the
161    /// first error.
162    ///
163    /// # Errors
164    /// The first error `f` returns.
165    pub fn try_map<U, E, F>(self, f: F) -> Result<CursorPage<U>, E>
166    where
167        F: FnMut(T) -> Result<U, E>,
168    {
169        let items = self.items.into_iter().map(f).collect::<Result<_, _>>()?;
170        Ok(CursorPage {
171            items,
172            next: self.next,
173            previous: self.previous,
174        })
175    }
176}
177
178impl<M> From<siderite_orm::CursorPage<M>> for CursorPage<M> {
179    fn from(page: siderite_orm::CursorPage<M>) -> Self {
180        Self {
181            items: page.items,
182            next: page.next,
183            previous: page.previous,
184        }
185    }
186}
187
188impl<T: Dump> Dump for CursorPage<T> {
189    fn serialize_dump<S: Serializer>(
190        &self,
191        serializer: S,
192        opts: &DumpOptions,
193    ) -> Result<S::Ok, S::Error> {
194        let mut out = serializer.serialize_struct("CursorPage", 3)?;
195        out.serialize_field("items", &DumpSerialize(&self.items[..], opts))?;
196        out.serialize_field("next", &self.next)?;
197        out.serialize_field("previous", &self.previous)?;
198        out.end()
199    }
200
201    fn dump(&self, opts: &DumpOptions) -> Result<Value, DumpError> {
202        Ok(serde_json::to_value(DumpSerialize(self, opts))?)
203    }
204}
205
206impl<T: Schema + 'static> Schema for CursorPage<T> {
207    fn schema(registry: &mut SchemaRegistry) -> SchemaObject {
208        let items = registry.subschema::<Vec<T>>().into_value();
209        let cursor = registry.subschema::<Option<String>>().into_value();
210        let properties = json!({
211            "items": items,
212            "next": cursor,
213            "previous": cursor,
214        });
215        let required = ["items", "next", "previous"];
216        SchemaObject::of_type("object")
217            .with("properties", properties)
218            .with("required", json!(required))
219    }
220}