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}