Skip to main content

cloud_sdk/pagination/
numbered.rs

1use crate::rate_limit::RateLimit;
2
3use super::{PageStrategy, PaginationBudget, PaginationError, PaginationProgress, SnapshotId};
4
5/// One-based provider-neutral page number.
6#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
7pub struct PageNumber(u64);
8
9impl PageNumber {
10    /// Creates a one-based page number.
11    pub const fn new(value: u64) -> Result<Self, PaginationError> {
12        if value == 0 {
13            return Err(PaginationError::PageZero);
14        }
15        Ok(Self(value))
16    }
17
18    /// Returns the raw page number.
19    #[must_use]
20    pub const fn get(self) -> u64 {
21        self.0
22    }
23}
24
25/// Validated metadata from one numbered-page response.
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub struct NumberedPageMetadata {
28    page: PageNumber,
29    page_size: u64,
30    previous_page: Option<PageNumber>,
31    next_page: Option<PageNumber>,
32    last_page: Option<PageNumber>,
33    total_entries: Option<u64>,
34}
35
36impl NumberedPageMetadata {
37    /// Creates coherent numbered navigation metadata.
38    pub const fn new(
39        page: PageNumber,
40        page_size: u64,
41        previous_page: Option<PageNumber>,
42        next_page: Option<PageNumber>,
43        last_page: Option<PageNumber>,
44        total_entries: Option<u64>,
45    ) -> Result<Self, PaginationError> {
46        if page_size == 0 {
47            return Err(PaginationError::PageSizeZero);
48        }
49        if let Some(previous) = previous_page {
50            let Some(expected) = page.0.checked_sub(1) else {
51                return Err(PaginationError::InvalidPreviousPage);
52            };
53            if previous.0 != expected {
54                return Err(PaginationError::InvalidPreviousPage);
55            }
56        }
57        if let Some(next) = next_page {
58            let Some(expected) = page.0.checked_add(1) else {
59                return Err(PaginationError::InvalidNextPage);
60            };
61            if next.0 != expected {
62                return Err(PaginationError::InvalidNextPage);
63            }
64        }
65        if let Some(last) = last_page {
66            if last.0 < page.0 || (page.0 < last.0) != next_page.is_some() {
67                return Err(PaginationError::InvalidLastPage);
68            }
69            if let Some(next) = next_page
70                && next.0 > last.0
71            {
72                return Err(PaginationError::InvalidLastPage);
73            }
74        }
75        Ok(Self {
76            page,
77            page_size,
78            previous_page,
79            next_page,
80            last_page,
81            total_entries,
82        })
83    }
84
85    /// Returns the current page.
86    #[must_use]
87    pub const fn page(self) -> PageNumber {
88        self.page
89    }
90
91    /// Returns the response page size.
92    #[must_use]
93    pub const fn page_size(self) -> u64 {
94        self.page_size
95    }
96
97    /// Returns the previous page when advertised.
98    #[must_use]
99    pub const fn previous_page(self) -> Option<PageNumber> {
100        self.previous_page
101    }
102
103    /// Returns the next page when advertised.
104    #[must_use]
105    pub const fn next_page(self) -> Option<PageNumber> {
106        self.next_page
107    }
108
109    /// Returns the final page when known.
110    #[must_use]
111    pub const fn last_page(self) -> Option<PageNumber> {
112        self.last_page
113    }
114
115    /// Returns the total matching entries when known.
116    #[must_use]
117    pub const fn total_entries(self) -> Option<u64> {
118        self.total_entries
119    }
120}
121
122/// Accepted numbered-page boundary.
123#[derive(Clone, Copy, Debug, Eq, PartialEq)]
124pub struct NumberedPageBoundary {
125    metadata: NumberedPageMetadata,
126    entries: usize,
127    rate_limit: Option<RateLimit>,
128    progress: PaginationProgress,
129}
130
131/// One decoded numbered-page response supplied to a pager driver.
132#[derive(Clone, Copy, Debug, Eq, PartialEq)]
133pub struct NumberedPageObservation<'snapshot> {
134    metadata: NumberedPageMetadata,
135    entries: usize,
136    rate_limit: Option<RateLimit>,
137    snapshot: Option<SnapshotId<'snapshot>>,
138}
139
140impl<'snapshot> NumberedPageObservation<'snapshot> {
141    /// Groups one complete decoded response observation.
142    #[must_use]
143    pub const fn new(
144        metadata: NumberedPageMetadata,
145        entries: usize,
146        rate_limit: Option<RateLimit>,
147        snapshot: Option<SnapshotId<'snapshot>>,
148    ) -> Self {
149        Self {
150            metadata,
151            entries,
152            rate_limit,
153            snapshot,
154        }
155    }
156}
157
158impl NumberedPageBoundary {
159    /// Returns validated provider metadata.
160    #[must_use]
161    pub const fn metadata(self) -> NumberedPageMetadata {
162        self.metadata
163    }
164
165    /// Returns decoded entry count.
166    #[must_use]
167    pub const fn entries(self) -> usize {
168        self.entries
169    }
170
171    /// Returns rate-limit metadata when supplied.
172    #[must_use]
173    pub const fn rate_limit(self) -> Option<RateLimit> {
174        self.rate_limit
175    }
176
177    /// Returns traversal counters after this page.
178    #[must_use]
179    pub const fn progress(self) -> PaginationProgress {
180        self.progress
181    }
182
183    /// Reports whether this page ended iteration.
184    #[must_use]
185    pub const fn is_terminal(self) -> bool {
186        self.metadata.next_page.is_none()
187    }
188}
189
190/// Stateful numbered-page strategy with locked traversal metadata.
191///
192/// This type is intentionally neither `Copy` nor `Clone`.
193pub struct NumberedPagination {
194    next_page: Option<PageNumber>,
195    expected_page_size: u64,
196    expected_total_entries: Option<u64>,
197    expected_last_page: Option<PageNumber>,
198    metadata_initialized: bool,
199    budget: PaginationBudget,
200}
201
202impl NumberedPagination {
203    /// Starts a numbered traversal.
204    pub fn new(
205        first_page: PageNumber,
206        expected_page_size: u64,
207        budget: PaginationBudget,
208    ) -> Result<Self, PaginationError> {
209        if expected_page_size == 0 {
210            return Err(PaginationError::PageSizeZero);
211        }
212        Ok(Self {
213            next_page: Some(first_page),
214            expected_page_size,
215            expected_total_entries: None,
216            expected_last_page: None,
217            metadata_initialized: false,
218            budget,
219        })
220    }
221
222    /// Returns the page the caller must request next.
223    pub const fn next_page(&self) -> Result<PageNumber, PaginationError> {
224        match self.next_page {
225            Some(page) => Ok(page),
226            None => Err(PaginationError::Complete),
227        }
228    }
229
230    /// Returns accepted counters.
231    #[must_use]
232    pub const fn progress(&self) -> PaginationProgress {
233        self.budget.progress()
234    }
235
236    /// Validates one response transactionally, then advances the strategy.
237    pub fn observe(
238        &mut self,
239        metadata: NumberedPageMetadata,
240        entries: usize,
241        rate_limit: Option<RateLimit>,
242        snapshot: Option<SnapshotId<'_>>,
243    ) -> Result<NumberedPageBoundary, PaginationError> {
244        let expected = self.next_page.ok_or(PaginationError::Complete)?;
245        if metadata.page != expected {
246            return Err(PaginationError::UnexpectedPosition);
247        }
248        if metadata.page_size != self.expected_page_size {
249            return Err(PaginationError::PageSizeChanged);
250        }
251        if self.metadata_initialized
252            && (metadata.total_entries != self.expected_total_entries
253                || metadata.last_page != self.expected_last_page)
254        {
255            return Err(PaginationError::TraversalChanged);
256        }
257        validate_entry_count(metadata, entries)?;
258        let has_continuation = metadata.next_page.is_some();
259        if entries == 0 && has_continuation {
260            return Err(PaginationError::EmptyPageWithContinuation);
261        }
262        let progress = self.budget.admit(entries, has_continuation, snapshot)?;
263        self.next_page = metadata.next_page;
264        if !self.metadata_initialized {
265            self.expected_total_entries = metadata.total_entries;
266            self.expected_last_page = metadata.last_page;
267            self.metadata_initialized = true;
268        }
269        Ok(NumberedPageBoundary {
270            metadata,
271            entries,
272            rate_limit,
273            progress,
274        })
275    }
276}
277
278fn validate_entry_count(
279    metadata: NumberedPageMetadata,
280    entries: usize,
281) -> Result<(), PaginationError> {
282    let entry_count = u64::try_from(entries).map_err(|_| PaginationError::InvalidEntryCount)?;
283    if entry_count > metadata.page_size {
284        return Err(PaginationError::InvalidEntryCount);
285    }
286    if let Some(total) = metadata.total_entries {
287        let offset = metadata
288            .page
289            .0
290            .checked_sub(1)
291            .and_then(|page| page.checked_mul(metadata.page_size))
292            .ok_or(PaginationError::InvalidEntryCount)?;
293        let expected = total.saturating_sub(offset).min(metadata.page_size);
294        let continuation = total
295            > offset
296                .checked_add(entry_count)
297                .ok_or(PaginationError::InvalidEntryCount)?;
298        if entry_count != expected || continuation != metadata.next_page.is_some() {
299            return Err(PaginationError::InvalidEntryCount);
300        }
301    }
302    Ok(())
303}
304
305impl core::fmt::Debug for NumberedPagination {
306    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
307        formatter
308            .debug_struct("NumberedPagination")
309            .field("next_page", &self.next_page)
310            .field("expected_page_size", &self.expected_page_size)
311            .field("traversal_metadata", &"[redacted]")
312            .field("budget", &self.budget)
313            .finish()
314    }
315}
316
317impl PageStrategy for NumberedPagination {
318    type Request = PageNumber;
319    type Observation<'observation> = NumberedPageObservation<'observation>;
320    type Boundary = NumberedPageBoundary;
321
322    fn next_request(&self) -> Result<Self::Request, PaginationError> {
323        self.next_page()
324    }
325
326    fn observe<'observation>(
327        &mut self,
328        observation: Self::Observation<'observation>,
329    ) -> Result<Self::Boundary, PaginationError> {
330        self.observe(
331            observation.metadata,
332            observation.entries,
333            observation.rate_limit,
334            observation.snapshot,
335        )
336    }
337}