Skip to main content

faucet_source_graphql/
stream.rs

1//! GraphQL stream executor.
2
3use crate::config::{GraphqlAuth, GraphqlPagination, GraphqlStreamConfig};
4use async_trait::async_trait;
5use base64::Engine as _;
6use faucet_core::util::{self, DEFAULT_ERROR_BODY_MAX_LEN};
7use faucet_core::{AuthSpec, Credential, FaucetError, SharedAuthProvider, Stream, StreamPage};
8use jsonpath_rust::JsonPath;
9use reqwest::Client;
10use serde_json::{Value, json};
11use std::collections::HashMap;
12use std::pin::Pin;
13use std::time::Duration;
14
15/// Retries on transient (5xx / connection) failures before giving up.
16const RETRY_MAX_ATTEMPTS: u32 = 3;
17/// Base exponential-backoff delay between retries.
18const RETRY_BASE_BACKOFF: Duration = Duration::from_millis(500);
19
20/// A configured GraphQL source that handles pagination and extraction.
21pub struct GraphqlStream {
22    config: GraphqlStreamConfig,
23    client: Client,
24    /// Optional shared auth provider. When set, it takes precedence over inline
25    /// auth. Used by the CLI to resolve `auth: { ref }`, and by library callers
26    /// who construct one provider and inject it into many sources.
27    auth_provider: Option<SharedAuthProvider>,
28    /// Retry policy for transient request failures. Defaulted in `new()` to
29    /// reproduce the legacy `RETRY_MAX_ATTEMPTS` / `RETRY_BASE_BACKOFF`
30    /// constants; overridable via [`with_retry_policy`](Self::with_retry_policy).
31    retry_policy: faucet_core::RetryPolicy,
32}
33
34/// Map a [`Credential`] from a shared provider onto the GraphQL [`GraphqlAuth`]
35/// representation so the existing header-application path can be reused.
36fn credential_to_auth(cred: Credential) -> GraphqlAuth {
37    match cred {
38        Credential::Bearer(token) => GraphqlAuth::Bearer { token },
39        Credential::Token(token) => GraphqlAuth::Custom {
40            headers: HashMap::from([("Authorization".into(), token)]),
41        },
42        Credential::Header { name, value } => GraphqlAuth::Custom {
43            headers: HashMap::from([(name, value)]),
44        },
45        Credential::Basic { username, password } => GraphqlAuth::Custom {
46            headers: HashMap::from([(
47                "Authorization".into(),
48                format!(
49                    "Basic {}",
50                    base64::engine::general_purpose::STANDARD
51                        .encode(format!("{username}:{password}"))
52                ),
53            )]),
54        },
55    }
56}
57
58impl GraphqlStream {
59    /// Create a new GraphQL stream from the given configuration.
60    pub fn new(config: GraphqlStreamConfig) -> Self {
61        Self {
62            config,
63            client: Client::new(),
64            auth_provider: None,
65            // Reproduce the legacy `execute_with_retry(RETRY_MAX_ATTEMPTS,
66            // RETRY_BASE_BACKOFF, …)` behavior exactly: `max_retries` is
67            // retries-after-first, so `max_attempts = RETRY_MAX_ATTEMPTS + 1`.
68            retry_policy: faucet_core::RetryPolicy {
69                max_attempts: RETRY_MAX_ATTEMPTS + 1,
70                backoff: faucet_core::BackoffKind::Exponential,
71                base: RETRY_BASE_BACKOFF,
72                max: Duration::from_secs(60),
73                jitter: true,
74                retry_on: faucet_core::RetryClassSet::default(),
75            },
76        }
77    }
78
79    /// Attach a custom [`RetryPolicy`](faucet_core::RetryPolicy) for transient
80    /// request failures, replacing the default derived from
81    /// `RETRY_MAX_ATTEMPTS` / `RETRY_BASE_BACKOFF`. Used by the CLI to inject a
82    /// pipeline-level `resilience:` policy into the source.
83    pub fn with_retry_policy(mut self, policy: faucet_core::RetryPolicy) -> Self {
84        self.retry_policy = policy;
85        self
86    }
87
88    /// Attach a shared [`AuthProvider`](faucet_core::AuthProvider). When set, the
89    /// provider supplies the credential for every request (taking precedence
90    /// over inline auth), so several sources can share one token with
91    /// single-flight refresh. Used by the CLI to resolve `auth: { ref }`, and by
92    /// library callers who construct one provider and inject it into many sources.
93    pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
94        self.auth_provider = Some(provider);
95        self
96    }
97
98    /// Fetch all records across all pages.
99    pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
100        self.fetch_all_with_context(&std::collections::HashMap::new())
101            .await
102    }
103
104    /// Fetch all records, merging parent context values into GraphQL variables.
105    async fn fetch_all_with_context(
106        &self,
107        context: &std::collections::HashMap<String, Value>,
108    ) -> Result<Vec<Value>, FaucetError> {
109        let mut all_records = Vec::new();
110        let mut cursor: Option<String> = None;
111        let mut pages_fetched = 0usize;
112        let mut warned_unresolved_has_next = false;
113        let mut cursor_guard = CursorGuard::new();
114
115        loop {
116            if let Some(max) = self.config.max_pages
117                && pages_fetched >= max
118            {
119                tracing::warn!("max pages ({max}) reached");
120                break;
121            }
122
123            let body = self.execute_query(&cursor, context).await?;
124            let records = self.extract_records(&body)?;
125            all_records.extend(records);
126            pages_fetched += 1;
127
128            // Check pagination.
129            match &self.config.pagination {
130                Some(pag) => {
131                    let (step, unresolved) = decide_next_page(&body, pag, cursor.as_deref());
132                    if unresolved && !warned_unresolved_has_next {
133                        tracing::warn!(
134                            path = %pag.has_next_page_path,
135                            "GraphQL has_next_page path did not resolve to a boolean; \
136                             deferring to cursor presence to decide pagination"
137                        );
138                        warned_unresolved_has_next = true;
139                    }
140                    match step {
141                        PageStep::Stop => break,
142                        PageStep::StopLoop => {
143                            tracing::warn!("cursor loop detected, stopping pagination");
144                            break;
145                        }
146                        PageStep::Advance(next) => {
147                            if cursor_guard.is_repeat(&next) {
148                                tracing::warn!(
149                                    "cursor cycle detected (cursor already seen), stopping pagination"
150                                );
151                                break;
152                            }
153                            cursor = Some(next);
154                        }
155                    }
156                }
157                None => break,
158            }
159        }
160
161        tracing::info!(
162            records = all_records.len(),
163            pages = pages_fetched,
164            "GraphQL fetch complete"
165        );
166        Ok(all_records)
167    }
168
169    /// Execute a single GraphQL query, merging parent context into variables.
170    async fn execute_query(
171        &self,
172        cursor: &Option<String>,
173        context: &std::collections::HashMap<String, Value>,
174    ) -> Result<Value, FaucetError> {
175        let mut variables = self.config.variables.clone();
176
177        // Merge parent context values into GraphQL variables.
178        if !context.is_empty()
179            && let Value::Object(ref mut map) = variables
180        {
181            for (key, value) in context {
182                map.insert(key.clone(), value.clone());
183            }
184        }
185
186        // Inject cursor and page size into variables.
187        if let (Some(pag), Some(cursor_val)) = (&self.config.pagination, cursor)
188            && let Value::Object(ref mut map) = variables
189        {
190            map.insert(pag.cursor_variable.clone(), json!(cursor_val));
191        }
192        // Inject `first:` (or whatever `page_size_variable` is named) from
193        // `batch_size`. `batch_size = 0` is the "use upstream default"
194        // sentinel — we omit the variable entirely in that case.
195        if let Some(pag) = &self.config.pagination
196            && self.config.batch_size != 0
197            && let Value::Object(map) = &mut variables
198        {
199            map.insert(
200                pag.page_size_variable.clone(),
201                json!(self.config.batch_size),
202            );
203        }
204
205        let payload = json!({
206            "query": self.config.query,
207            "variables": variables,
208        });
209
210        let mut req = self
211            .client
212            .post(&self.config.endpoint)
213            .headers(self.config.headers.clone())
214            .json(&payload);
215
216        // Resolve credentials to concrete auth. A shared auth provider (from
217        // `auth: { ref }` or injected by a library caller) takes precedence;
218        // otherwise the inline auth config is used directly.
219        let effective_auth: GraphqlAuth = if let Some(provider) = &self.auth_provider {
220            credential_to_auth(provider.credential().await?)
221        } else {
222            match &self.config.auth {
223                AuthSpec::Inline(a) => a.clone(),
224                AuthSpec::Reference(r) => {
225                    return Err(FaucetError::Auth(format!(
226                        "auth references provider '{}' but no provider was supplied; \
227                         set one via the CLI `auth:` catalog or `with_auth_provider`",
228                        r.name
229                    )));
230                }
231            }
232        };
233
234        // Apply resolved auth to the request.
235        match effective_auth {
236            GraphqlAuth::None => {}
237            GraphqlAuth::Bearer { token } => {
238                req = req.bearer_auth(token);
239            }
240            GraphqlAuth::Custom { headers } => {
241                let mut hm = reqwest::header::HeaderMap::new();
242                for (name, value) in &headers {
243                    let n =
244                        reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
245                            FaucetError::Auth(format!("invalid custom header name {name:?}: {e}"))
246                        })?;
247                    let v = reqwest::header::HeaderValue::from_str(value).map_err(|e| {
248                        FaucetError::Auth(format!("invalid custom header value for {name:?}: {e}"))
249                    })?;
250                    hm.insert(n, v);
251                }
252                req = req.headers(hm);
253            }
254        }
255
256        // Retry transient failures (5xx / connection resets) with jittered
257        // backoff, matching the REST source's reliability layer (#78/#16).
258        // GraphQL-level `errors` in a 200 body are application errors and are
259        // handled below — they are not retried here.
260        let body: Value = faucet_core::execute_with_policy(&self.retry_policy, None, || {
261            let attempt = req.try_clone();
262            async move {
263                let req = attempt.ok_or_else(|| {
264                    FaucetError::Source("graphql: request is not cloneable for retry".into())
265                })?;
266                let resp = req.send().await.map_err(FaucetError::Http)?;
267                let resp = util::check_http_response(resp, DEFAULT_ERROR_BODY_MAX_LEN).await?;
268                resp.json().await.map_err(FaucetError::Http)
269            }
270        })
271        .await?;
272
273        // Check for GraphQL-level errors.
274        if let Some(errors) = body.get("errors")
275            && let Some(arr) = errors.as_array()
276            && !arr.is_empty()
277        {
278            let msg = arr
279                .iter()
280                .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
281                .collect::<Vec<_>>()
282                .join("; ");
283            // Surface "first: must be non-null" / similar variable validation
284            // errors as `FaucetError::Config` so callers can react to the
285            // `batch_size = 0` sentinel hitting a schema that requires a
286            // non-null page-size argument. Detect by message substring —
287            // GraphQL servers don't standardise an error-code field.
288            let lower = msg.to_lowercase();
289            if self.config.batch_size == 0
290                && let Some(pag) = &self.config.pagination
291            {
292                let var_name = pag.page_size_variable.to_lowercase();
293                if lower.contains(&var_name)
294                    && (lower.contains("non-null")
295                        || lower.contains("non null")
296                        || lower.contains("must not be null")
297                        || lower.contains("cannot be null")
298                        || lower.contains("required"))
299                {
300                    return Err(FaucetError::Config(format!(
301                        "batch_size = 0 requires the upstream to accept a null {}: argument \
302                         (GraphQL errors: {msg})",
303                        pag.page_size_variable
304                    )));
305                }
306            }
307            return Err(FaucetError::HttpStatus {
308                status: 200,
309                url: self.config.endpoint.clone(),
310                body: format!("GraphQL errors: {msg}"),
311            });
312        }
313
314        Ok(body)
315    }
316
317    /// Extract records from a GraphQL response using the configured JSONPath.
318    fn extract_records(&self, body: &Value) -> Result<Vec<Value>, FaucetError> {
319        match &self.config.records_path {
320            Some(path) => util::extract_records(body, Some(path)),
321            None => {
322                // GraphQL-specific: return the `data` field as a single
323                // record. A `data` that is JSON null (or absent entirely)
324                // means there is nothing to extract — emit an empty page
325                // rather than forwarding a bogus null record to the sink
326                // (#146 LOW).
327                match body.get("data") {
328                    Some(Value::Null) | None => Ok(Vec::new()),
329                    Some(data) => Ok(vec![data.clone()]),
330                }
331            }
332        }
333    }
334
335    /// Core pagination loop yielded as a [`StreamPage`] stream.
336    ///
337    /// Each upstream GraphQL response → one [`StreamPage`]. The page size
338    /// variable in the request comes from [`GraphqlStreamConfig::batch_size`];
339    /// `batch_size = 0` omits it so the upstream uses its own default page
340    /// size and emits a single page.
341    ///
342    /// Bookmarks are always `None` — the GraphQL source has no
343    /// incremental-replication mode today. The
344    /// [`bookmark_emitted`-style trailing-checkpoint](https://github.com/faucet-hq/faucet-stream/commit/e6fdca5)
345    /// guard from the REST source is preserved structurally so any future
346    /// incremental mode picks it up without re-deriving the pattern.
347    fn stream_pages_inner(
348        &self,
349        context: &std::collections::HashMap<String, Value>,
350    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + '_>> {
351        // Own the context so it can live inside the async-stream generator.
352        let owned_context: std::collections::HashMap<String, Value> = context.clone();
353
354        Box::pin(async_stream::try_stream! {
355            let mut cursor: Option<String> = None;
356            let mut cursor_guard = CursorGuard::new();
357            let mut pages_fetched = 0usize;
358            let mut warned_unresolved_has_next = false;
359            // No incremental replication today — `running_max` stays `None`.
360            // The structure mirrors the REST source so a future replication
361            // mode can plug into the same scaffolding without reworking the
362            // bookmark guard.
363            let running_max: Option<Value> = None;
364            let mut bookmark_emitted = false;
365
366            loop {
367                if let Some(max) = self.config.max_pages
368                    && pages_fetched >= max
369                {
370                    tracing::warn!("max pages ({max}) reached");
371                    break;
372                }
373
374                let body = self.execute_query(&cursor, &owned_context).await?;
375                let records = self.extract_records(&body)?;
376                pages_fetched += 1;
377
378                // Advance pagination state BEFORE yielding the current page,
379                // so the bookmark is only attached on the final page.
380                let has_next = match &self.config.pagination {
381                    Some(pag) => {
382                        let (step, unresolved) =
383                            decide_next_page(&body, pag, cursor.as_deref());
384                        if unresolved && !warned_unresolved_has_next {
385                            tracing::warn!(
386                                path = %pag.has_next_page_path,
387                                "GraphQL has_next_page path did not resolve to a boolean; \
388                                 deferring to cursor presence to decide pagination"
389                            );
390                            warned_unresolved_has_next = true;
391                        }
392                        match step {
393                            PageStep::Stop => false,
394                            PageStep::StopLoop => {
395                                tracing::warn!("cursor loop detected, stopping pagination");
396                                false
397                            }
398                            PageStep::Advance(next) => {
399                                if cursor_guard.is_repeat(&next) {
400                                    tracing::warn!(
401                                        "cursor cycle detected (cursor already seen), stopping pagination"
402                                    );
403                                    false
404                                } else {
405                                    cursor = Some(next);
406                                    true
407                                }
408                            }
409                        }
410                    }
411                    None => false,
412                };
413
414                if has_next {
415                    // Intermediate page — bookmark stays `None`.
416                    yield StreamPage { records, bookmark: None };
417                } else {
418                    // Final page — attach the consolidated bookmark (always
419                    // `None` until incremental mode lands).
420                    bookmark_emitted = running_max.is_some();
421                    yield StreamPage {
422                        records,
423                        bookmark: running_max.clone(),
424                    };
425                    break;
426                }
427            }
428
429            // Trailing checkpoint: if the loop exited (e.g. via `max_pages`
430            // truncation) without carrying the bookmark on a real page, emit
431            // one empty page carrying it so the pipeline persists progress.
432            // No-op today because `running_max` is always `None`, but kept so
433            // a future incremental mode inherits the guard from the REST
434            // source's regression fix (commit e6fdca5).
435            if !bookmark_emitted && running_max.is_some() {
436                yield StreamPage {
437                    records: Vec::new(),
438                    bookmark: running_max,
439                };
440            }
441
442            tracing::info!(
443                pages = pages_fetched,
444                batch_size = self.config.batch_size,
445                "GraphQL source stream complete",
446            );
447        })
448    }
449}
450
451#[async_trait]
452impl faucet_core::Source for GraphqlStream {
453    async fn fetch_with_context(
454        &self,
455        context: &std::collections::HashMap<String, serde_json::Value>,
456    ) -> Result<Vec<Value>, FaucetError> {
457        self.fetch_all_with_context(context).await
458    }
459
460    /// Stream GraphQL responses page-by-page without buffering the full
461    /// result set. The trait-level `batch_size` argument is ignored in
462    /// favour of [`GraphqlStreamConfig::batch_size`] — the config field is
463    /// the user-facing knob the README documents, and routing the
464    /// pipeline-supplied hint through it would silently override an
465    /// explicit config value.
466    fn stream_pages<'a>(
467        &'a self,
468        context: &'a std::collections::HashMap<String, Value>,
469        _batch_size: usize,
470    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
471        self.stream_pages_inner(context)
472    }
473
474    fn connector_name(&self) -> &'static str {
475        "graphql"
476    }
477
478    fn config_schema(&self) -> serde_json::Value {
479        serde_json::to_value(faucet_core::schema_for!(GraphqlStreamConfig))
480            .expect("schema serialization")
481    }
482
483    fn dataset_uri(&self) -> String {
484        faucet_core::redact_uri_credentials(&self.config.endpoint)
485    }
486}
487
488fn extract_string(body: &Value, path: &str) -> Option<String> {
489    let results = body.query(path).ok()?;
490    match results.first()? {
491        Value::String(s) => Some(s.clone()),
492        _ => None,
493    }
494}
495
496fn extract_bool(body: &Value, path: &str) -> Option<bool> {
497    let results = body.query(path).ok()?;
498    results.first()?.as_bool()
499}
500
501/// What to do after fetching a page.
502#[derive(Debug, PartialEq)]
503enum PageStep {
504    /// No further pages (has-next is `false`, or there is no next cursor).
505    Stop,
506    /// The server returned the cursor we just used — advancing would re-fetch
507    /// the same page. Caller warns and stops.
508    StopLoop,
509    /// Fetch another page with this cursor.
510    Advance(String),
511}
512
513/// Pure pagination-advance decision shared by the eager and streaming paths.
514///
515/// `prev_cursor` is the cursor just used (for loop detection). The returned
516/// bool is `true` when the configured `has_next_page_path` did **not** resolve
517/// to a boolean: that is treated as "can't tell" and we **defer to cursor
518/// presence** rather than silently stopping — an unmatched has-next path must
519/// not drop the remaining pages of a paginated result (F52). The caller warns
520/// once on that condition.
521fn decide_next_page(
522    body: &Value,
523    pag: &GraphqlPagination,
524    prev_cursor: Option<&str>,
525) -> (PageStep, bool) {
526    let (stop, unresolved) = match extract_bool(body, &pag.has_next_page_path) {
527        Some(false) => (true, false),
528        Some(true) => (false, false),
529        // Path absent / not a boolean: defer the decision to the cursor signal.
530        None => (false, true),
531    };
532    if stop {
533        return (PageStep::Stop, unresolved);
534    }
535    match extract_string(body, &pag.cursor_path) {
536        None => (PageStep::Stop, unresolved),
537        Some(next) if Some(next.as_str()) == prev_cursor => (PageStep::StopLoop, unresolved),
538        Some(next) => (PageStep::Advance(next), unresolved),
539    }
540}
541
542/// Bounded record of recently-advanced pagination cursors.
543///
544/// [`decide_next_page`] only compares against the *immediately previous* cursor,
545/// so a server that returns `hasNextPage: true` while cycling its cursor across
546/// two or more values (`c1→c2→c1→c2…`) would never trip that guard and — with
547/// `max_pages` unset — paginate forever, re-emitting the same pages (#466 M2).
548/// This catches any such cycle by remembering the cursors already seen.
549///
550/// Bounded to [`Self::CAP`] entries so the streaming path keeps its O(page)
551/// memory guarantee on a legitimately large result set (whose cursors are all
552/// distinct, so eviction never causes a false positive). A cycle length beyond
553/// the cap is not realistic for a real endpoint.
554struct CursorGuard {
555    seen: HashMap<String, ()>,
556    order: std::collections::VecDeque<String>,
557}
558
559impl CursorGuard {
560    const CAP: usize = 4096;
561
562    fn new() -> Self {
563        Self {
564            seen: HashMap::new(),
565            order: std::collections::VecDeque::new(),
566        }
567    }
568
569    /// Record `cursor`; return `true` if it had already been seen (a cycle).
570    fn is_repeat(&mut self, cursor: &str) -> bool {
571        if self.seen.contains_key(cursor) {
572            return true;
573        }
574        if self.order.len() >= Self::CAP
575            && let Some(old) = self.order.pop_front()
576        {
577            self.seen.remove(&old);
578        }
579        self.seen.insert(cursor.to_string(), ());
580        self.order.push_back(cursor.to_string());
581        false
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn extract_string_from_json() {
591        let body = json!({"data": {"users": {"pageInfo": {"endCursor": "abc123"}}}});
592        assert_eq!(
593            extract_string(&body, "$.data.users.pageInfo.endCursor"),
594            Some("abc123".into())
595        );
596    }
597
598    #[test]
599    fn extract_bool_from_json() {
600        let body = json!({"data": {"users": {"pageInfo": {"hasNextPage": true}}}});
601        assert_eq!(
602            extract_bool(&body, "$.data.users.pageInfo.hasNextPage"),
603            Some(true)
604        );
605    }
606
607    fn pageinfo_pagination() -> GraphqlPagination {
608        GraphqlPagination {
609            has_next_page_path: "$.data.users.pageInfo.hasNextPage".into(),
610            cursor_path: "$.data.users.pageInfo.endCursor".into(),
611            ..GraphqlPagination::default()
612        }
613    }
614
615    #[test]
616    fn decide_next_page_advances_when_has_next_true() {
617        let body =
618            json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c2"}}}});
619        let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
620        assert_eq!(step, PageStep::Advance("c2".into()));
621        assert!(!unresolved);
622    }
623
624    #[test]
625    fn decide_next_page_stops_when_has_next_false() {
626        let body =
627            json!({"data": {"users": {"pageInfo": {"hasNextPage": false, "endCursor": "c2"}}}});
628        let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
629        assert_eq!(step, PageStep::Stop);
630        assert!(!unresolved);
631    }
632
633    #[test]
634    fn decide_next_page_detects_cursor_loop() {
635        let body =
636            json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c1"}}}});
637        let (step, _) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
638        assert_eq!(step, PageStep::StopLoop);
639    }
640
641    #[test]
642    fn decide_next_page_defers_to_cursor_when_has_next_unresolved() {
643        // F52: an absent / non-boolean has-next path must NOT silently stop
644        // pagination. With a valid distinct next cursor we keep going, and the
645        // unresolved flag is raised so the caller warns once.
646        let body = json!({"data": {"users": {"pageInfo": {"endCursor": "c2"}}}}); // no hasNextPage
647        let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
648        assert_eq!(
649            step,
650            PageStep::Advance("c2".into()),
651            "unresolved has-next must defer to cursor presence, not stop"
652        );
653        assert!(unresolved, "the caller is told to warn once");
654
655        // Unresolved has-next AND no cursor → genuinely stop (nothing to follow).
656        let body_no_cursor = json!({"data": {"users": {"pageInfo": {}}}});
657        let (step, unresolved) =
658            decide_next_page(&body_no_cursor, &pageinfo_pagination(), Some("c1"));
659        assert_eq!(step, PageStep::Stop);
660        assert!(unresolved);
661    }
662
663    #[test]
664    fn extract_records_with_path() {
665        let config =
666            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { users { id } }")
667                .records_path("$.data.users[*]");
668        let stream = GraphqlStream::new(config);
669        let body = json!({"data": {"users": [{"id": 1}, {"id": 2}]}});
670        let records = stream.extract_records(&body).unwrap();
671        assert_eq!(records.len(), 2);
672        assert_eq!(records[0]["id"], 1);
673    }
674
675    #[test]
676    fn extract_records_without_path_returns_data() {
677        let config =
678            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
679        let stream = GraphqlStream::new(config);
680        let body = json!({"data": {"user": {"id": 1}}});
681        let records = stream.extract_records(&body).unwrap();
682        assert_eq!(records.len(), 1);
683        assert_eq!(records[0]["user"]["id"], 1);
684    }
685
686    #[test]
687    fn extract_records_without_path_null_data_yields_empty() {
688        // A response of `{"data": null}` must NOT emit a bogus null record:
689        // `data` being JSON null means there is nothing to extract, so the
690        // page is empty (#146 LOW).
691        let config =
692            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
693        let stream = GraphqlStream::new(config);
694        let body = json!({ "data": null });
695        let records = stream.extract_records(&body).unwrap();
696        assert!(
697            records.is_empty(),
698            "expected empty Vec for null `data`, got {records:?}"
699        );
700    }
701
702    #[test]
703    fn extract_records_without_path_absent_data_yields_empty() {
704        // No `data` field at all → nothing to extract → empty page (matches
705        // the null-data case rather than forwarding the whole body).
706        let config =
707            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
708        let stream = GraphqlStream::new(config);
709        let body = json!({ "extensions": { "foo": 1 } });
710        let records = stream.extract_records(&body).unwrap();
711        assert!(
712            records.is_empty(),
713            "expected empty Vec when `data` is absent, got {records:?}"
714        );
715    }
716
717    #[test]
718    fn dataset_uri_returns_endpoint() {
719        use faucet_core::Source;
720        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
721            "https://api.example.com/graphql",
722            "query { id }",
723        ));
724        assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
725    }
726
727    #[test]
728    fn dataset_uri_redacts_credentials() {
729        use faucet_core::Source;
730        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
731            "https://user:pw@api.example.com/graphql",
732            "query { id }",
733        ));
734        assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
735    }
736
737    #[test]
738    fn default_retry_policy_reproduces_legacy_constants() {
739        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
740            "https://api.example.com/graphql",
741            "query { id }",
742        ));
743        assert_eq!(stream.retry_policy.max_attempts, RETRY_MAX_ATTEMPTS + 1);
744        assert_eq!(stream.retry_policy.base, RETRY_BASE_BACKOFF);
745    }
746
747    #[test]
748    fn with_retry_policy_overrides_the_default() {
749        let policy = faucet_core::RetryPolicy {
750            max_attempts: 9,
751            base: Duration::from_secs(7),
752            ..faucet_core::RetryPolicy::default()
753        };
754        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
755            "https://api.example.com/graphql",
756            "query { id }",
757        ))
758        .with_retry_policy(policy);
759        assert_eq!(stream.retry_policy.max_attempts, 9);
760        assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
761    }
762
763    #[test]
764    fn cursor_guard_detects_repeats_and_bounds_memory() {
765        let mut g = CursorGuard::new();
766        assert!(!g.is_repeat("a"));
767        assert!(!g.is_repeat("b"));
768        // Any earlier cursor repeating is a cycle, not just the adjacent one.
769        assert!(g.is_repeat("a"));
770        assert!(g.is_repeat("b"));
771
772        // Bounded: after CAP distinct cursors, the oldest is evicted, so the
773        // set never grows without bound on a legitimately large pagination.
774        let mut g = CursorGuard::new();
775        for i in 0..CursorGuard::CAP {
776            assert!(!g.is_repeat(&format!("c{i}")));
777        }
778        assert_eq!(g.order.len(), CursorGuard::CAP);
779        // One more distinct cursor evicts the oldest ("c0").
780        assert!(!g.is_repeat("overflow"));
781        assert_eq!(g.order.len(), CursorGuard::CAP);
782        assert!(!g.seen.contains_key("c0"), "oldest cursor evicted");
783        assert!(g.seen.contains_key("overflow"));
784    }
785}