Skip to main content

apify_client/clients/
pagination.rs

1//! Generic lazy pagination shared by every collection client.
2//!
3//! The reference JavaScript client returns an `AsyncIterable` from every collection `list()`
4//! (via its base `_listPaginatedFromCallback`), so callers can iterate across all pages without
5//! manually tracking offsets. Rust cannot return a value that is simultaneously a `Future` and a
6//! `Stream`, so the idiomatic equivalent here is a dedicated `iterate()` method on each collection
7//! client that returns a [`ListIterator`]. The paging logic lives here once so every client stays
8//! a thin wrapper over it (don't-repeat-yourself).
9
10use std::collections::VecDeque;
11use std::future::Future;
12use std::pin::Pin;
13
14use crate::common::PaginationList;
15use crate::error::ApifyClientResult;
16
17/// A boxed future yielding one page of results. Boxed so [`ListIterator`] can hold a fetcher
18/// for any concrete collection client without being generic over its (unnameable) future type.
19type PageFuture<T> = Pin<Box<dyn Future<Output = ApifyClientResult<PaginationList<T>>> + Send>>;
20
21/// Fetches one page: the arguments are the absolute `offset` to start at and the per-page `limit`
22/// to request (`None` = let the API pick its default page size). Implementations capture a clone of
23/// the collection client and the caller's list options, overriding only the offset and limit per
24/// page.
25type PageFetcher<T> = Box<dyn Fn(i64, Option<i64>) -> PageFuture<T> + Send + Sync>;
26
27/// Generates the body of a collection client's `iterate()` method for the common shape: an
28/// options struct with `offset`/`limit` fields plus a `list(options)` method. It clones the
29/// client, reads the caller's start offset and total-item cap from the options, and builds a
30/// [`ListIterator`] whose per-page fetcher overrides only `offset`/`limit` before calling
31/// `list`. This removes the ~15 lines of identical closure boilerplate that would otherwise be
32/// copied into every collection client (the don't-repeat-yourself goal of this module).
33///
34/// Collections whose listing does not fit this shape build their iterator directly instead:
35/// the run listing takes a separate `filter` argument, dataset items use `list_items::<T>`, and
36/// an Actor's env vars are non-paginated ([`ListIterator::new_single_page`]).
37macro_rules! list_iterator {
38    ($self:expr, $options:expr, $list:ident) => {{
39        let client = $self.clone();
40        let options = $options;
41        let start = options.offset.unwrap_or(0);
42        let total_limit = options.limit;
43        $crate::clients::pagination::ListIterator::new(
44            start,
45            total_limit,
46            Box::new(move |offset, page_limit| {
47                let client = client.clone();
48                let mut options = options.clone();
49                options.offset = Some(offset);
50                options.limit = page_limit;
51                Box::pin(async move { client.$list(options).await })
52            }),
53        )
54    }};
55}
56pub(crate) use list_iterator;
57
58/// Returns the smaller of two optional positive limits, treating a non-positive value as "no
59/// limit" (`None`). Mirrors the reference client's `minForLimitParam`, where the API treats `0`
60/// as an absent limit.
61fn min_positive_limit(a: Option<i64>, b: Option<i64>) -> Option<i64> {
62    let a = a.filter(|&x| x > 0);
63    let b = b.filter(|&x| x > 0);
64    match (a, b) {
65        (Some(x), Some(y)) => Some(x.min(y)),
66        (Some(x), None) | (None, Some(x)) => Some(x),
67        (None, None) => None,
68    }
69}
70
71/// A lazy, page-fetching async iterator over an offset/limit-paginated list endpoint.
72///
73/// Created by a collection client's `iterate()` method. Each call to [`next`](Self::next)
74/// returns the next item, transparently fetching the following page from the API once the
75/// local buffer drains, until the listing is exhausted (or the caller's total-item cap is hit).
76///
77/// # `limit` vs. page size
78///
79/// The caller's `limit` (from the list options passed to `iterate()`) is a **cap on the total
80/// number of items the iterator yields**, matching the reference JavaScript client's
81/// `_listPaginatedFromCallback`, where `options.limit` bounds the whole async-iterable and a
82/// separate `chunkSize` controls page size. Leaving `limit` unset (or `0`) iterates the entire
83/// listing. The page size is a distinct concern: set it with [`with_chunk_size`](Self::with_chunk_size);
84/// when unset, the API's default page size is used. So `iterate(opts{ limit: 10 })` yields at most
85/// 10 items, and `iterate(opts).with_chunk_size(50)` fetches 50 per request while yielding
86/// everything.
87///
88/// **Large caps and the first page.** When a total cap is set but no page size is, the first page
89/// requests `limit == cap` (the reference client does the same, via
90/// `minForLimitParam(options.limit, options.chunkSize)`). If you set a very large cap — larger
91/// than the endpoint's maximum `limit` — also call [`with_chunk_size`](Self::with_chunk_size) with
92/// a value at or below that maximum, so the first request stays within the endpoint's accepted
93/// range rather than asking for the whole cap up front.
94///
95/// # Example
96/// ```no_run
97/// use apify_client::ApifyClient;
98///
99/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
100/// let client = ApifyClient::new("my-api-token");
101/// let mut it = client.actors().iterate(Default::default());
102/// while let Some(actor) = it.next().await? {
103///     println!("{}", actor.id);
104/// }
105/// # Ok(())
106/// # }
107/// ```
108pub struct ListIterator<T> {
109    fetch: PageFetcher<T>,
110    buffer: VecDeque<T>,
111    /// Absolute offset of the next page to request.
112    next_offset: i64,
113    /// Number of items still allowed under the caller's total-item cap; `None` = uncapped.
114    /// Decremented by each page's item count as pages are fetched.
115    remaining: Option<i64>,
116    /// Page size to request per fetch (the reference client's `chunkSize`); `None` = let the API
117    /// choose its default page size.
118    chunk_size: Option<i64>,
119    /// When `true`, the underlying endpoint is not offset-paginated: the first fetch returns the
120    /// whole result set, so the iterator stops after it rather than requesting a second page.
121    single_page: bool,
122    /// Set once the listing has been fully consumed.
123    exhausted: bool,
124}
125
126impl<T> ListIterator<T> {
127    /// Builds an iterator that starts at `start_offset`, yields at most `total_limit` items across
128    /// all pages (`None`/`0` = uncapped), and fetches offset-paginated pages via `fetch`.
129    pub(crate) fn new(start_offset: i64, total_limit: Option<i64>, fetch: PageFetcher<T>) -> Self {
130        let cap = total_limit.filter(|&l| l > 0);
131        Self {
132            fetch,
133            buffer: VecDeque::new(),
134            next_offset: start_offset,
135            remaining: cap,
136            chunk_size: None,
137            single_page: false,
138            exhausted: false,
139        }
140    }
141
142    /// Builds an iterator over an endpoint that is **not** offset-paginated (it returns every
143    /// item in one response, e.g. an Actor version's environment variables). `fetch` is called
144    /// exactly once; the iterator does not attempt a second page, so it does not depend on the
145    /// endpoint reporting a page `limit` and cannot refetch the same items.
146    pub(crate) fn new_single_page(fetch: PageFetcher<T>) -> Self {
147        Self {
148            single_page: true,
149            ..Self::new(0, None, fetch)
150        }
151    }
152
153    /// Sets the page size (items requested per API call) for this iteration — the reference
154    /// client's `chunkSize`. This controls only how many items each page fetch requests, never how
155    /// many the iterator yields in total (that is the caller's `limit`; see the type docs). A
156    /// non-positive value lets the API choose its default page size.
157    pub fn with_chunk_size(mut self, chunk_size: i64) -> Self {
158        self.chunk_size = (chunk_size > 0).then_some(chunk_size);
159        self
160    }
161
162    /// Returns the next item, or `None` when the listing is exhausted. Fetches another page from
163    /// the API when the local buffer is empty.
164    pub async fn next(&mut self) -> ApifyClientResult<Option<T>> {
165        if let Some(item) = self.buffer.pop_front() {
166            return Ok(Some(item));
167        }
168        if self.exhausted {
169            return Ok(None);
170        }
171
172        // Request the smaller of the items still allowed under the caller's cap (`remaining`) and
173        // the configured page size (`chunk_size`); `None` lets the API pick its default page size.
174        // On the first page `remaining` is the full cap, matching the reference client's initial
175        // `minForLimitParam(options.limit, options.chunkSize)`.
176        let page_limit = min_positive_limit(self.remaining, self.chunk_size);
177        let page = (self.fetch)(self.next_offset, page_limit).await?;
178        let received = page.items.len() as i64;
179
180        // Enforce the caller's total-item cap exactly, even if the API returns more than the
181        // requested page limit.
182        let mut items = page.items;
183        if let Some(rem) = self.remaining {
184            if received > rem {
185                items.truncate(rem as usize);
186            }
187        }
188
189        self.next_offset += received;
190        if let Some(rem) = self.remaining.as_mut() {
191            *rem -= received;
192        }
193
194        // Decide whether more pages remain.
195        if self.single_page {
196            // Non-paginated endpoint: everything came back in one response.
197            self.exhausted = true;
198        } else if received == 0 {
199            // Empty page: nothing more to read. This is the primary backstop and matches the
200            // reference client, whose loop stops as soon as a page returns no items.
201            self.exhausted = true;
202        } else if matches!(self.remaining, Some(r) if r <= 0) {
203            // Reached the caller's total-item cap.
204            self.exhausted = true;
205        } else if page.total > 0 {
206            // The endpoint reports a usable total, so drive termination by position, like the
207            // reference `_listPaginatedFromCallback`. A short page is deliberately NOT treated as
208            // terminal here: with dataset item filters (`skip_empty`/`clean`/`skip_hidden`) a full,
209            // non-final window can return fewer items than requested while more remain at higher
210            // offsets, so short-page detection would silently truncate. The empty-page backstop
211            // above ends the walk instead.
212            if self.next_offset >= page.total {
213                self.exhausted = true;
214            }
215        } else {
216            // No usable total (endpoint reports `total == 0`): fall back to short-page detection —
217            // a page shorter than the size the API says it served (`page.limit`) is the last one.
218            // A non-positive reported limit means the endpoint is not offset-paginated at all.
219            if page.limit <= 0 || received < page.limit {
220                self.exhausted = true;
221            }
222        }
223
224        self.buffer.extend(items);
225        Ok(self.buffer.pop_front())
226    }
227
228    /// Eagerly drains the iterator into a single `Vec`, fetching every remaining page.
229    ///
230    /// Convenience for callers that want all items at once; prefer [`next`](Self::next) to
231    /// process items as they stream in without buffering the whole result set.
232    pub async fn collect_all(mut self) -> ApifyClientResult<Vec<T>> {
233        let mut out = Vec::new();
234        while let Some(item) = self.next().await? {
235            out.push(item);
236        }
237        Ok(out)
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::{ListIterator, PageFetcher};
244    use crate::common::PaginationList;
245    use std::sync::atomic::{AtomicUsize, Ordering};
246    use std::sync::Arc;
247
248    /// Builds a fetcher that serves `all` as offset-paginated pages, honouring the per-page `limit`
249    /// requested by the iterator (falling back to `default_page` when the iterator requests none).
250    /// Reports `total = all.len()` only when `report_total`, and echoes the effective page size back
251    /// as the response `limit`. Counts how many times it is called so tests can assert page-request
252    /// behaviour.
253    fn slicing_fetcher(
254        all: Vec<i64>,
255        default_page: i64,
256        report_total: bool,
257        calls: Arc<AtomicUsize>,
258    ) -> PageFetcher<i64> {
259        let all = Arc::new(all);
260        Box::new(move |offset, limit| {
261            calls.fetch_add(1, Ordering::SeqCst);
262            let all = all.clone();
263            let page_size = limit.filter(|&l| l > 0).unwrap_or(default_page);
264            Box::pin(async move {
265                let start = (offset.max(0) as usize).min(all.len());
266                let end = (start + page_size.max(0) as usize).min(all.len());
267                let items = all[start..end].to_vec();
268                Ok(PaginationList {
269                    total: if report_total { all.len() as i64 } else { 0 },
270                    offset,
271                    limit: page_size,
272                    count: items.len() as i64,
273                    desc: false,
274                    items,
275                })
276            })
277        })
278    }
279
280    #[tokio::test]
281    async fn walks_all_pages_using_reported_total() {
282        let calls = Arc::new(AtomicUsize::new(0));
283        let iter = ListIterator::new(
284            0,
285            None,
286            slicing_fetcher((0..5).collect(), 2, true, calls.clone()),
287        )
288        .with_chunk_size(2);
289        assert_eq!(iter.collect_all().await.unwrap(), vec![0, 1, 2, 3, 4]);
290        // Pages: [0,1] [2,3] [4]. next_offset reaches total (5) on the third page, so no extra
291        // empty request.
292        assert_eq!(calls.load(Ordering::SeqCst), 3);
293    }
294
295    #[tokio::test]
296    async fn walks_all_pages_when_total_is_zero() {
297        // Emulates an endpoint that does not report a usable total: short-page detection ends it.
298        let calls = Arc::new(AtomicUsize::new(0));
299        let iter = ListIterator::new(0, None, slicing_fetcher((0..5).collect(), 2, false, calls))
300            .with_chunk_size(2);
301        assert_eq!(iter.collect_all().await.unwrap(), vec![0, 1, 2, 3, 4]);
302    }
303
304    #[tokio::test]
305    async fn total_zero_exact_multiple_terminates_on_empty_page() {
306        // 4 items, page size 2, no usable total: pages [0,1] [2,3] [] — the empty page ends it.
307        let calls = Arc::new(AtomicUsize::new(0));
308        let iter = ListIterator::new(
309            0,
310            None,
311            slicing_fetcher((0..4).collect(), 2, false, calls.clone()),
312        )
313        .with_chunk_size(2);
314        assert_eq!(iter.collect_all().await.unwrap(), vec![0, 1, 2, 3]);
315        assert_eq!(calls.load(Ordering::SeqCst), 3);
316    }
317
318    #[tokio::test]
319    async fn reported_total_avoids_extra_request_on_exact_multiple() {
320        // 4 items, page size 2, total known: stops after the second full page (no empty fetch).
321        let calls = Arc::new(AtomicUsize::new(0));
322        let iter = ListIterator::new(
323            0,
324            None,
325            slicing_fetcher((0..4).collect(), 2, true, calls.clone()),
326        )
327        .with_chunk_size(2);
328        assert_eq!(iter.collect_all().await.unwrap(), vec![0, 1, 2, 3]);
329        assert_eq!(calls.load(Ordering::SeqCst), 2);
330    }
331
332    #[tokio::test]
333    async fn non_final_short_page_with_reported_total_does_not_truncate() {
334        // Guards the termination logic, NOT filter de-duplication: every page is "short" (it
335        // returns fewer items than the page size the API reports) while the endpoint reports a
336        // large total. Total-driven termination must keep going and yield every item, ending
337        // only on the empty page. (This fetcher advances offset by the page size and never
338        // overlaps windows, so it does not model the sparse-window duplicate behaviour
339        // documented on `iterate_items`; it only proves a short page is not treated as terminal.)
340        let all: Vec<i64> = (0..6).collect();
341        let calls = Arc::new(AtomicUsize::new(0));
342        let counter = calls.clone();
343        let all = Arc::new(all);
344        let fetch: PageFetcher<i64> = Box::new(move |offset, _limit| {
345            counter.fetch_add(1, Ordering::SeqCst);
346            let all = all.clone();
347            Box::pin(async move {
348                // Serve at most 2 items per page but report a page limit of 4, so every non-final
349                // page is strictly short (received 2 < reported limit 4). `total` is reported large
350                // (100) so termination can only come from the empty page, never a short page.
351                let start = (offset.max(0) as usize).min(all.len());
352                let end = (start + 2).min(all.len());
353                let items = all[start..end].to_vec();
354                Ok(PaginationList {
355                    total: 100,
356                    offset,
357                    limit: 4,
358                    count: items.len() as i64,
359                    desc: false,
360                    items,
361                })
362            })
363        });
364        let iter = ListIterator::new(0, None, fetch).with_chunk_size(4);
365        let got = iter.collect_all().await.unwrap();
366        // Every item must be yielded despite each page being short.
367        assert_eq!(got, vec![0, 1, 2, 3, 4, 5]);
368        // Pages: [0,1] [2,3] [4,5] [] — four calls, none terminated early by short-page detection.
369        assert_eq!(calls.load(Ordering::SeqCst), 4);
370    }
371
372    #[tokio::test]
373    async fn total_limit_caps_items_yielded() {
374        // `limit` is a total-item cap: with 100 items available, iterate should yield exactly 3.
375        let calls = Arc::new(AtomicUsize::new(0));
376        let iter = ListIterator::new(
377            0,
378            Some(3),
379            slicing_fetcher((0..100).collect(), 1000, true, calls.clone()),
380        );
381        assert_eq!(iter.collect_all().await.unwrap(), vec![0, 1, 2]);
382        // Requesting limit=3 up front means a single page suffices.
383        assert_eq!(calls.load(Ordering::SeqCst), 1);
384    }
385
386    #[tokio::test]
387    async fn total_limit_with_smaller_chunk_size_pages_and_caps() {
388        // limit=5 total cap, chunk_size=2 page size: pages of 2 until 5 items are yielded.
389        let calls = Arc::new(AtomicUsize::new(0));
390        let iter = ListIterator::new(
391            0,
392            Some(5),
393            slicing_fetcher((0..100).collect(), 1000, true, calls.clone()),
394        )
395        .with_chunk_size(2);
396        assert_eq!(iter.collect_all().await.unwrap(), vec![0, 1, 2, 3, 4]);
397        // Pages: [0,1] [2,3] [4] — the last page is trimmed to the remaining budget of 1.
398        assert_eq!(calls.load(Ordering::SeqCst), 3);
399    }
400
401    #[tokio::test]
402    async fn cap_truncates_page_that_exceeds_remaining_budget() {
403        // Exercises the cap-truncation branch (`received > rem`): the endpoint ignores the
404        // requested page limit and returns MORE items than the caller's total cap allows. The
405        // iterator must trim the over-long page to the remaining budget and yield exactly `limit`
406        // items, fetching only once.
407        let calls = Arc::new(AtomicUsize::new(0));
408        let counter = calls.clone();
409        let fetch: PageFetcher<i64> = Box::new(move |offset, _limit| {
410            counter.fetch_add(1, Ordering::SeqCst);
411            Box::pin(async move {
412                // Always return 5 items regardless of the requested limit.
413                Ok(PaginationList {
414                    total: 100,
415                    offset,
416                    limit: 5,
417                    count: 5,
418                    desc: false,
419                    items: vec![0, 1, 2, 3, 4],
420                })
421            })
422        });
423        // Total cap of 3, but the page delivers 5 → must be truncated to [0, 1, 2].
424        let iter = ListIterator::new(0, Some(3), fetch);
425        assert_eq!(iter.collect_all().await.unwrap(), vec![0, 1, 2]);
426        assert_eq!(
427            calls.load(Ordering::SeqCst),
428            1,
429            "cap reached on the first (over-long) page, so no second fetch"
430        );
431    }
432
433    #[tokio::test]
434    async fn honours_caller_start_offset() {
435        let calls = Arc::new(AtomicUsize::new(0));
436        let iter = ListIterator::new(2, None, slicing_fetcher((0..5).collect(), 10, true, calls));
437        assert_eq!(iter.collect_all().await.unwrap(), vec![2, 3, 4]);
438    }
439
440    #[tokio::test]
441    async fn single_page_fetches_once_and_stops() {
442        // A non-paginated endpoint: the fetcher ignores offset and returns everything every call,
443        // reporting limit == count. Multi-page mode would loop forever here; single-page must not.
444        let calls = Arc::new(AtomicUsize::new(0));
445        let counter = calls.clone();
446        let fetch: PageFetcher<i64> = Box::new(move |offset, _limit| {
447            counter.fetch_add(1, Ordering::SeqCst);
448            Box::pin(async move {
449                Ok(PaginationList {
450                    total: 0,
451                    offset,
452                    limit: 3,
453                    count: 3,
454                    desc: false,
455                    items: vec![10, 20, 30],
456                })
457            })
458        });
459        let iter = ListIterator::new_single_page(fetch);
460        assert_eq!(iter.collect_all().await.unwrap(), vec![10, 20, 30]);
461        assert_eq!(
462            calls.load(Ordering::SeqCst),
463            1,
464            "single-page must fetch exactly once"
465        );
466    }
467
468    #[tokio::test]
469    async fn empty_first_page_yields_nothing() {
470        let calls = Arc::new(AtomicUsize::new(0));
471        let mut iter = ListIterator::new(0, None, slicing_fetcher(vec![], 5, true, calls));
472        assert!(iter.next().await.unwrap().is_none());
473    }
474}