Skip to main content

aster_forge_api/
pagination.rs

1//! Bounded pagination queries, offset responses and page-loading mechanics.
2use crate::{ApiSchema, Result};
3use serde::{Deserialize, Serialize};
4use std::future::Future;
5#[cfg(all(debug_assertions, feature = "openapi"))]
6use utoipa::{IntoParams, ToSchema};
7/// Default page size for folder-list style endpoints.
8pub const DEFAULT_FOLDER_LIMIT: u64 = 200;
9/// Default page size for file-list style endpoints.
10pub const DEFAULT_FILE_LIMIT: u64 = 100;
11/// Default page size for cursor-based endpoints.
12pub const DEFAULT_PAGE_LIMIT: u64 = 100;
13/// Maximum accepted page size for offset and cursor pagination.
14pub const MAX_PAGE_SIZE: u64 = 1000;
15
16/// Query parameters for limit/offset pagination.
17#[derive(Debug, Clone, Copy, Default, Deserialize)]
18#[cfg_attr(
19    all(debug_assertions, feature = "openapi"),
20    derive(IntoParams, ToSchema)
21)]
22pub struct LimitOffsetQuery {
23    /// Requested page size.
24    pub limit: Option<u64>,
25    /// Requested offset from the beginning of the result set.
26    pub offset: Option<u64>,
27}
28
29impl LimitOffsetQuery {
30    /// Returns the requested limit clamped to `[1, max]`, or `default` when absent.
31    #[must_use]
32    pub fn limit_or(&self, default: u64, max: u64) -> u64 {
33        self.limit.map_or(default, |v| v.clamp(1, max))
34    }
35
36    /// Returns the requested offset, or zero when absent.
37    #[must_use]
38    pub fn offset(&self) -> u64 {
39        self.offset.unwrap_or(0)
40    }
41}
42
43/// Query parameters for limit-only cursor pagination.
44#[derive(Debug, Clone, Copy, Default, Deserialize)]
45#[cfg_attr(
46    all(debug_assertions, feature = "openapi"),
47    derive(IntoParams, ToSchema)
48)]
49pub struct LimitQuery {
50    /// Requested page size.
51    pub limit: Option<u64>,
52}
53
54impl LimitQuery {
55    /// Returns the requested limit clamped to `[1, max]`, or `default` when absent.
56    #[must_use]
57    pub fn limit_or(&self, default: u64, max: u64) -> u64 {
58        self.limit.map_or(default, |value| value.clamp(1, max))
59    }
60
61    /// Returns the requested limit clamped against the crate's default cursor limits.
62    #[must_use]
63    pub fn limit(&self) -> u64 {
64        self.limit_or(DEFAULT_PAGE_LIMIT, MAX_PAGE_SIZE)
65    }
66}
67
68/// Serialized offset page response.
69#[derive(Debug, Clone, Serialize)]
70#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(ToSchema))]
71pub struct OffsetPage<T: Serialize + ApiSchema> {
72    /// Items in the current page.
73    pub items: Vec<T>,
74    /// Total number of items matching the query.
75    pub total: u64,
76    /// Effective page size.
77    pub limit: u64,
78    /// Offset used for this page.
79    pub offset: u64,
80}
81
82impl<T: Serialize + ApiSchema> OffsetPage<T> {
83    /// Creates a new offset page.
84    #[must_use]
85    pub fn new(items: Vec<T>, total: u64, limit: u64, offset: u64) -> Self {
86        Self {
87            items,
88            total,
89            limit,
90            offset,
91        }
92    }
93}
94
95/// Loads an offset page by clamping `limit`, invoking `fetch`, and wrapping the result.
96///
97/// # Errors
98///
99/// Returns the error produced by `fetch`.
100pub async fn load_offset_page<T, F, Fut>(
101    limit: u64,
102    offset: u64,
103    max_limit: u64,
104    fetch: F,
105) -> Result<OffsetPage<T>>
106where
107    T: Serialize + ApiSchema,
108    F: FnOnce(u64, u64) -> Fut,
109    Fut: Future<Output = Result<(Vec<T>, u64)>>,
110{
111    let limit = limit.clamp(1, max_limit);
112    let (items, total) = fetch(limit, offset).await?;
113    Ok(OffsetPage::new(items, total, limit, offset))
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::ApiError;
120    use serde_json::json;
121
122    #[test]
123    fn queries_preserve_large_offsets_and_clamp_limits_without_integer_loss() {
124        let query = LimitOffsetQuery {
125            limit: Some(u64::MAX),
126            offset: Some(u64::MAX),
127        };
128        assert_eq!(query.limit_or(20, MAX_PAGE_SIZE), MAX_PAGE_SIZE);
129        assert_eq!(query.offset(), u64::MAX);
130        assert_eq!(
131            LimitQuery {
132                limit: Some(u64::MAX)
133            }
134            .limit(),
135            MAX_PAGE_SIZE
136        );
137        assert_eq!(LimitQuery { limit: Some(0) }.limit_or(20, 1), 1);
138        let page = OffsetPage::new(Vec::<u8>::new(), u64::MAX, MAX_PAGE_SIZE, u64::MAX);
139        assert_eq!(
140            serde_json::to_value(page).unwrap(),
141            json!({"items":[],"total":u64::MAX,"limit":MAX_PAGE_SIZE,"offset":u64::MAX})
142        );
143    }
144    #[test]
145    fn limit_offset_query_applies_defaults_and_bounds() {
146        let query = LimitOffsetQuery {
147            limit: None,
148            offset: None,
149        };
150        assert_eq!(query.limit_or(50, 100), 50);
151        assert_eq!(query.offset(), 0);
152
153        let query = LimitOffsetQuery {
154            limit: Some(0),
155            offset: Some(25),
156        };
157        assert_eq!(query.limit_or(50, 100), 1);
158        assert_eq!(query.offset(), 25);
159
160        let query = LimitOffsetQuery {
161            limit: Some(500),
162            offset: Some(10),
163        };
164        assert_eq!(query.limit_or(50, 100), 100);
165    }
166
167    #[test]
168    fn limit_query_applies_defaults_and_bounds() {
169        let query = LimitQuery { limit: None };
170        assert_eq!(query.limit_or(50, 100), 50);
171        assert_eq!(query.limit(), DEFAULT_PAGE_LIMIT);
172
173        let query = LimitQuery { limit: Some(0) };
174        assert_eq!(query.limit_or(50, 100), 1);
175
176        let query = LimitQuery { limit: Some(500) };
177        assert_eq!(query.limit_or(50, 100), 100);
178    }
179
180    #[test]
181    fn offset_page_serializes_expected_shape() {
182        let page = OffsetPage::new(vec!["a", "b"], 10, 2, 4);
183        let value = serde_json::to_value(page).unwrap();
184
185        assert_eq!(
186            value,
187            json!({
188                "items": ["a", "b"],
189                "total": 10,
190                "limit": 2,
191                "offset": 4
192            })
193        );
194    }
195
196    #[tokio::test]
197    async fn load_offset_page_clamps_limit_and_forwards_offset() {
198        let page = load_offset_page(500, 30, 100, |limit, offset| async move {
199            assert_eq!(limit, 100);
200            assert_eq!(offset, 30);
201            Ok((vec![1, 2, 3], 9))
202        })
203        .await
204        .unwrap();
205
206        assert_eq!(page.items, vec![1, 2, 3]);
207        assert_eq!(page.total, 9);
208        assert_eq!(page.limit, 100);
209        assert_eq!(page.offset, 30);
210    }
211
212    #[tokio::test]
213    async fn load_offset_page_propagates_fetch_error() {
214        let error = load_offset_page::<u8, _, _>(10, 0, 100, |_limit, _offset| async {
215            Err(ApiError::new("fetch failed"))
216        })
217        .await
218        .unwrap_err();
219
220        assert_eq!(error.message(), "fetch failed");
221    }
222}