Skip to main content

faucet_source_rest/pagination/
mod.rs

1//! Pagination strategies for REST APIs.
2
3pub mod cursor;
4pub mod link_header;
5pub mod next_link_body;
6pub mod offset;
7pub mod page;
8
9use faucet_core::FaucetError;
10use reqwest::header::HeaderMap;
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::HashMap;
15
16/// Supported pagination strategies.
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
18#[serde(tag = "type")]
19pub enum PaginationStyle {
20    None,
21    Cursor {
22        next_token_path: String,
23        param_name: String,
24    },
25    LinkHeader,
26    /// The full URL of the next page is embedded in the response body.
27    /// `next_link_path` is a JSONPath expression pointing to that URL field
28    /// (e.g. `"$.next_link"`).  Pagination stops when the field is absent,
29    /// null, or an empty string.
30    NextLinkInBody {
31        next_link_path: String,
32    },
33    PageNumber {
34        param_name: String,
35        start_page: usize,
36        page_size: Option<usize>,
37        page_size_param: Option<String>,
38    },
39    Offset {
40        offset_param: String,
41        limit_param: String,
42        limit: usize,
43        total_path: Option<String>,
44    },
45}
46
47/// Internal state tracked across pages.
48#[derive(Debug, Default)]
49pub struct PaginationState {
50    pub page: usize,
51    pub next_token: Option<String>,
52    pub offset: usize,
53    pub next_link: Option<String>,
54    /// The previous page's token/link, used for loop detection.
55    /// If `advance()` produces the same value twice in a row, pagination
56    /// is stuck and we stop rather than looping forever.
57    #[doc(hidden)]
58    pub previous_token: Option<String>,
59    /// Fingerprint of the previous page's body, used by `PageNumber` loop
60    /// detection: APIs that clamp an out-of-range page to the last page and
61    /// re-return it (non-empty) would otherwise loop until `max_pages`.
62    #[doc(hidden)]
63    pub previous_page_fingerprint: Option<u64>,
64    /// Set by [`PaginationStyle::advance`] when the body-fingerprint stagnation
65    /// guard fires: the page just handed to `advance` is a duplicate of the
66    /// previous one and the caller must **drop** it rather than emit it a second
67    /// time (audit #321 L1). Only the content-stagnation guards set it; a normal
68    /// last-page stop leaves it `false` so the final page is still emitted.
69    #[doc(hidden)]
70    pub current_page_is_duplicate: bool,
71}
72
73/// Cheap, stable fingerprint of a response body for content-stagnation
74/// loop detection.
75fn body_fingerprint(body: &Value) -> u64 {
76    use std::hash::{Hash, Hasher};
77    let mut h = std::collections::hash_map::DefaultHasher::new();
78    body.to_string().hash(&mut h);
79    h.finish()
80}
81
82impl PaginationStyle {
83    pub fn apply_params(&self, params: &mut HashMap<String, String>, state: &PaginationState) {
84        match self {
85            PaginationStyle::None => {}
86            PaginationStyle::Cursor { param_name, .. } => {
87                cursor::apply_params(params, param_name, &state.next_token);
88            }
89            PaginationStyle::LinkHeader => {}
90            PaginationStyle::NextLinkInBody { .. } => {}
91            PaginationStyle::PageNumber {
92                param_name,
93                start_page,
94                page_size,
95                page_size_param,
96            } => {
97                page::apply_params(
98                    params,
99                    param_name,
100                    *start_page,
101                    state.page,
102                    *page_size,
103                    page_size_param.as_deref(),
104                );
105            }
106            PaginationStyle::Offset {
107                offset_param,
108                limit_param,
109                limit,
110                ..
111            } => {
112                offset::apply_params(params, offset_param, limit_param, state.offset, *limit);
113            }
114        }
115    }
116
117    /// Advance pagination state based on the response body and headers.
118    /// Returns `true` if there is a next page to fetch.
119    ///
120    /// Includes **loop detection**: if a cursor or next-link value is identical
121    /// to the previous page's value, pagination stops with a warning instead of
122    /// looping forever.
123    pub fn advance(
124        &self,
125        body: &Value,
126        headers: &HeaderMap,
127        state: &mut PaginationState,
128        record_count: usize,
129    ) -> Result<bool, FaucetError> {
130        match self {
131            PaginationStyle::None => Ok(false),
132            PaginationStyle::Cursor {
133                next_token_path, ..
134            } => {
135                let has_next = cursor::advance(body, next_token_path, &mut state.next_token)?;
136                if has_next {
137                    if state.next_token == state.previous_token {
138                        tracing::warn!(
139                            "pagination loop detected: cursor {:?} repeated — stopping",
140                            state.next_token
141                        );
142                        return Ok(false);
143                    }
144                    state.previous_token = state.next_token.clone();
145                }
146                Ok(has_next)
147            }
148            PaginationStyle::LinkHeader => match link_header::extract_next_link(headers) {
149                Some(link) => {
150                    if Some(&link) == state.previous_token.as_ref() {
151                        tracing::warn!(
152                            "pagination loop detected: link {link:?} repeated — stopping"
153                        );
154                        state.next_link = None;
155                        return Ok(false);
156                    }
157                    state.previous_token = Some(link.clone());
158                    state.next_link = Some(link);
159                    Ok(true)
160                }
161                None => {
162                    state.next_link = None;
163                    Ok(false)
164                }
165            },
166            PaginationStyle::NextLinkInBody { next_link_path } => {
167                let has_next = next_link_body::advance(body, next_link_path, &mut state.next_link)?;
168                if has_next {
169                    if state.next_link == state.previous_token {
170                        tracing::warn!(
171                            "pagination loop detected: next_link {:?} repeated — stopping",
172                            state.next_link
173                        );
174                        return Ok(false);
175                    }
176                    state.previous_token = state.next_link.clone();
177                }
178                Ok(has_next)
179            }
180            PaginationStyle::PageNumber { .. } => {
181                state.page += 1;
182                if record_count == 0 {
183                    return Ok(false);
184                }
185                // Content-stagnation guard: some APIs clamp an out-of-range
186                // page to the last page and return it again (non-empty), which
187                // would loop until `max_pages` and duplicate records. Stop if
188                // this page's body is identical to the previous one (#78/#15).
189                let fp = body_fingerprint(body);
190                if state.previous_page_fingerprint == Some(fp) {
191                    tracing::warn!(
192                        "pagination loop detected: PageNumber returned an identical page — stopping"
193                    );
194                    // The current page IS the duplicate — signal the caller to
195                    // drop it rather than emit it a second time (#321 L1).
196                    state.current_page_is_duplicate = true;
197                    return Ok(false);
198                }
199                state.previous_page_fingerprint = Some(fp);
200                Ok(true)
201            }
202            PaginationStyle::Offset {
203                limit, total_path, ..
204            } => {
205                let has_next = offset::advance(
206                    body,
207                    &mut state.offset,
208                    record_count,
209                    *limit,
210                    total_path.as_deref(),
211                )?;
212                // Content-stagnation guard (#264 F18): a server that ignores
213                // the `offset` parameter re-returns the identical first page
214                // forever. With `total_path` absent (commonly omitted) the
215                // record-count heuristic keeps `has_next` true on every full
216                // page, so the run would loop until `max_pages`, duplicating
217                // records to the sink. Mirror the PageNumber guard: stop if
218                // this page's body is identical to the previous one. A
219                // zero-record / short page has already returned `false` above,
220                // so this only fires on a genuinely repeated full page.
221                //
222                // Scoped to `total_path.is_none()`: when `total_path` is set,
223                // `offset::advance` has an authoritative stop condition (offset
224                // reaches total), and a paging-metadata body that legitimately
225                // repeats (e.g. `{"total": N}` echoed on every page) must not
226                // be mistaken for stagnation.
227                if has_next && total_path.is_none() {
228                    let fp = body_fingerprint(body);
229                    if state.previous_page_fingerprint == Some(fp) {
230                        tracing::warn!(
231                            "pagination loop detected: Offset returned an identical page \
232                             (server likely ignoring the offset parameter) — stopping"
233                        );
234                        // Drop this duplicate page rather than emit it (#321 L1).
235                        state.current_page_is_duplicate = true;
236                        return Ok(false);
237                    }
238                    state.previous_page_fingerprint = Some(fp);
239                }
240                Ok(has_next)
241            }
242        }
243    }
244}