Skip to main content

cloud_sdk/pagination/
numbered.rs

1use crate::rate_limit::RateLimit;
2
3use super::{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
131impl NumberedPageBoundary {
132    /// Returns validated provider metadata.
133    #[must_use]
134    pub const fn metadata(self) -> NumberedPageMetadata {
135        self.metadata
136    }
137
138    /// Returns decoded entry count.
139    #[must_use]
140    pub const fn entries(self) -> usize {
141        self.entries
142    }
143
144    /// Returns rate-limit metadata when supplied.
145    #[must_use]
146    pub const fn rate_limit(self) -> Option<RateLimit> {
147        self.rate_limit
148    }
149
150    /// Returns traversal counters after this page.
151    #[must_use]
152    pub const fn progress(self) -> PaginationProgress {
153        self.progress
154    }
155
156    /// Reports whether this page ended iteration.
157    #[must_use]
158    pub const fn is_terminal(self) -> bool {
159        self.metadata.next_page.is_none()
160    }
161}
162
163/// Stateful numbered-page strategy with locked traversal metadata.
164///
165/// This type is intentionally neither `Copy` nor `Clone`.
166pub struct NumberedPagination {
167    next_page: Option<PageNumber>,
168    expected_page_size: u64,
169    expected_total_entries: Option<u64>,
170    expected_last_page: Option<PageNumber>,
171    metadata_initialized: bool,
172    budget: PaginationBudget,
173}
174
175impl NumberedPagination {
176    /// Starts a numbered traversal.
177    pub fn new(
178        first_page: PageNumber,
179        expected_page_size: u64,
180        budget: PaginationBudget,
181    ) -> Result<Self, PaginationError> {
182        if expected_page_size == 0 {
183            return Err(PaginationError::PageSizeZero);
184        }
185        Ok(Self {
186            next_page: Some(first_page),
187            expected_page_size,
188            expected_total_entries: None,
189            expected_last_page: None,
190            metadata_initialized: false,
191            budget,
192        })
193    }
194
195    /// Returns the page the caller must request next.
196    pub const fn next_page(&self) -> Result<PageNumber, PaginationError> {
197        match self.next_page {
198            Some(page) => Ok(page),
199            None => Err(PaginationError::Complete),
200        }
201    }
202
203    /// Returns accepted counters.
204    #[must_use]
205    pub const fn progress(&self) -> PaginationProgress {
206        self.budget.progress()
207    }
208
209    /// Validates one response transactionally, then advances the strategy.
210    pub fn observe(
211        &mut self,
212        metadata: NumberedPageMetadata,
213        entries: usize,
214        rate_limit: Option<RateLimit>,
215        snapshot: Option<SnapshotId<'_>>,
216    ) -> Result<NumberedPageBoundary, PaginationError> {
217        let expected = self.next_page.ok_or(PaginationError::Complete)?;
218        if metadata.page != expected {
219            return Err(PaginationError::UnexpectedPosition);
220        }
221        if metadata.page_size != self.expected_page_size {
222            return Err(PaginationError::PageSizeChanged);
223        }
224        if self.metadata_initialized
225            && (metadata.total_entries != self.expected_total_entries
226                || metadata.last_page != self.expected_last_page)
227        {
228            return Err(PaginationError::TraversalChanged);
229        }
230        validate_entry_count(metadata, entries)?;
231        let has_continuation = metadata.next_page.is_some();
232        if entries == 0 && has_continuation {
233            return Err(PaginationError::EmptyPageWithContinuation);
234        }
235        let progress = self.budget.admit(entries, has_continuation, snapshot)?;
236        self.next_page = metadata.next_page;
237        if !self.metadata_initialized {
238            self.expected_total_entries = metadata.total_entries;
239            self.expected_last_page = metadata.last_page;
240            self.metadata_initialized = true;
241        }
242        Ok(NumberedPageBoundary {
243            metadata,
244            entries,
245            rate_limit,
246            progress,
247        })
248    }
249}
250
251fn validate_entry_count(
252    metadata: NumberedPageMetadata,
253    entries: usize,
254) -> Result<(), PaginationError> {
255    let entry_count = u64::try_from(entries).map_err(|_| PaginationError::InvalidEntryCount)?;
256    if entry_count > metadata.page_size {
257        return Err(PaginationError::InvalidEntryCount);
258    }
259    if let Some(total) = metadata.total_entries {
260        let offset = metadata
261            .page
262            .0
263            .checked_sub(1)
264            .and_then(|page| page.checked_mul(metadata.page_size))
265            .ok_or(PaginationError::InvalidEntryCount)?;
266        let expected = total.saturating_sub(offset).min(metadata.page_size);
267        let continuation = total
268            > offset
269                .checked_add(entry_count)
270                .ok_or(PaginationError::InvalidEntryCount)?;
271        if entry_count != expected || continuation != metadata.next_page.is_some() {
272            return Err(PaginationError::InvalidEntryCount);
273        }
274    }
275    Ok(())
276}
277
278impl core::fmt::Debug for NumberedPagination {
279    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
280        formatter
281            .debug_struct("NumberedPagination")
282            .field("next_page", &self.next_page)
283            .field("expected_page_size", &self.expected_page_size)
284            .field("traversal_metadata", &"[redacted]")
285            .field("budget", &self.budget)
286            .finish()
287    }
288}