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}