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 config_schema(&self) -> serde_json::Value {
475        serde_json::to_value(faucet_core::schema_for!(GraphqlStreamConfig))
476            .expect("schema serialization")
477    }
478
479    fn dataset_uri(&self) -> String {
480        faucet_core::redact_uri_credentials(&self.config.endpoint)
481    }
482}
483
484fn extract_string(body: &Value, path: &str) -> Option<String> {
485    let results = body.query(path).ok()?;
486    match results.first()? {
487        Value::String(s) => Some(s.clone()),
488        _ => None,
489    }
490}
491
492fn extract_bool(body: &Value, path: &str) -> Option<bool> {
493    let results = body.query(path).ok()?;
494    results.first()?.as_bool()
495}
496
497/// What to do after fetching a page.
498#[derive(Debug, PartialEq)]
499enum PageStep {
500    /// No further pages (has-next is `false`, or there is no next cursor).
501    Stop,
502    /// The server returned the cursor we just used — advancing would re-fetch
503    /// the same page. Caller warns and stops.
504    StopLoop,
505    /// Fetch another page with this cursor.
506    Advance(String),
507}
508
509/// Pure pagination-advance decision shared by the eager and streaming paths.
510///
511/// `prev_cursor` is the cursor just used (for loop detection). The returned
512/// bool is `true` when the configured `has_next_page_path` did **not** resolve
513/// to a boolean: that is treated as "can't tell" and we **defer to cursor
514/// presence** rather than silently stopping — an unmatched has-next path must
515/// not drop the remaining pages of a paginated result (F52). The caller warns
516/// once on that condition.
517fn decide_next_page(
518    body: &Value,
519    pag: &GraphqlPagination,
520    prev_cursor: Option<&str>,
521) -> (PageStep, bool) {
522    let (stop, unresolved) = match extract_bool(body, &pag.has_next_page_path) {
523        Some(false) => (true, false),
524        Some(true) => (false, false),
525        // Path absent / not a boolean: defer the decision to the cursor signal.
526        None => (false, true),
527    };
528    if stop {
529        return (PageStep::Stop, unresolved);
530    }
531    match extract_string(body, &pag.cursor_path) {
532        None => (PageStep::Stop, unresolved),
533        Some(next) if Some(next.as_str()) == prev_cursor => (PageStep::StopLoop, unresolved),
534        Some(next) => (PageStep::Advance(next), unresolved),
535    }
536}
537
538/// Bounded record of recently-advanced pagination cursors.
539///
540/// [`decide_next_page`] only compares against the *immediately previous* cursor,
541/// so a server that returns `hasNextPage: true` while cycling its cursor across
542/// two or more values (`c1→c2→c1→c2…`) would never trip that guard and — with
543/// `max_pages` unset — paginate forever, re-emitting the same pages (#466 M2).
544/// This catches any such cycle by remembering the cursors already seen.
545///
546/// Bounded to [`Self::CAP`] entries so the streaming path keeps its O(page)
547/// memory guarantee on a legitimately large result set (whose cursors are all
548/// distinct, so eviction never causes a false positive). A cycle length beyond
549/// the cap is not realistic for a real endpoint.
550struct CursorGuard {
551    seen: HashMap<String, ()>,
552    order: std::collections::VecDeque<String>,
553}
554
555impl CursorGuard {
556    const CAP: usize = 4096;
557
558    fn new() -> Self {
559        Self {
560            seen: HashMap::new(),
561            order: std::collections::VecDeque::new(),
562        }
563    }
564
565    /// Record `cursor`; return `true` if it had already been seen (a cycle).
566    fn is_repeat(&mut self, cursor: &str) -> bool {
567        if self.seen.contains_key(cursor) {
568            return true;
569        }
570        if self.order.len() >= Self::CAP
571            && let Some(old) = self.order.pop_front()
572        {
573            self.seen.remove(&old);
574        }
575        self.seen.insert(cursor.to_string(), ());
576        self.order.push_back(cursor.to_string());
577        false
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn extract_string_from_json() {
587        let body = json!({"data": {"users": {"pageInfo": {"endCursor": "abc123"}}}});
588        assert_eq!(
589            extract_string(&body, "$.data.users.pageInfo.endCursor"),
590            Some("abc123".into())
591        );
592    }
593
594    #[test]
595    fn extract_bool_from_json() {
596        let body = json!({"data": {"users": {"pageInfo": {"hasNextPage": true}}}});
597        assert_eq!(
598            extract_bool(&body, "$.data.users.pageInfo.hasNextPage"),
599            Some(true)
600        );
601    }
602
603    fn pageinfo_pagination() -> GraphqlPagination {
604        GraphqlPagination {
605            has_next_page_path: "$.data.users.pageInfo.hasNextPage".into(),
606            cursor_path: "$.data.users.pageInfo.endCursor".into(),
607            ..GraphqlPagination::default()
608        }
609    }
610
611    #[test]
612    fn decide_next_page_advances_when_has_next_true() {
613        let body =
614            json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c2"}}}});
615        let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
616        assert_eq!(step, PageStep::Advance("c2".into()));
617        assert!(!unresolved);
618    }
619
620    #[test]
621    fn decide_next_page_stops_when_has_next_false() {
622        let body =
623            json!({"data": {"users": {"pageInfo": {"hasNextPage": false, "endCursor": "c2"}}}});
624        let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
625        assert_eq!(step, PageStep::Stop);
626        assert!(!unresolved);
627    }
628
629    #[test]
630    fn decide_next_page_detects_cursor_loop() {
631        let body =
632            json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c1"}}}});
633        let (step, _) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
634        assert_eq!(step, PageStep::StopLoop);
635    }
636
637    #[test]
638    fn decide_next_page_defers_to_cursor_when_has_next_unresolved() {
639        // F52: an absent / non-boolean has-next path must NOT silently stop
640        // pagination. With a valid distinct next cursor we keep going, and the
641        // unresolved flag is raised so the caller warns once.
642        let body = json!({"data": {"users": {"pageInfo": {"endCursor": "c2"}}}}); // no hasNextPage
643        let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
644        assert_eq!(
645            step,
646            PageStep::Advance("c2".into()),
647            "unresolved has-next must defer to cursor presence, not stop"
648        );
649        assert!(unresolved, "the caller is told to warn once");
650
651        // Unresolved has-next AND no cursor → genuinely stop (nothing to follow).
652        let body_no_cursor = json!({"data": {"users": {"pageInfo": {}}}});
653        let (step, unresolved) =
654            decide_next_page(&body_no_cursor, &pageinfo_pagination(), Some("c1"));
655        assert_eq!(step, PageStep::Stop);
656        assert!(unresolved);
657    }
658
659    #[test]
660    fn extract_records_with_path() {
661        let config =
662            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { users { id } }")
663                .records_path("$.data.users[*]");
664        let stream = GraphqlStream::new(config);
665        let body = json!({"data": {"users": [{"id": 1}, {"id": 2}]}});
666        let records = stream.extract_records(&body).unwrap();
667        assert_eq!(records.len(), 2);
668        assert_eq!(records[0]["id"], 1);
669    }
670
671    #[test]
672    fn extract_records_without_path_returns_data() {
673        let config =
674            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
675        let stream = GraphqlStream::new(config);
676        let body = json!({"data": {"user": {"id": 1}}});
677        let records = stream.extract_records(&body).unwrap();
678        assert_eq!(records.len(), 1);
679        assert_eq!(records[0]["user"]["id"], 1);
680    }
681
682    #[test]
683    fn extract_records_without_path_null_data_yields_empty() {
684        // A response of `{"data": null}` must NOT emit a bogus null record:
685        // `data` being JSON null means there is nothing to extract, so the
686        // page is empty (#146 LOW).
687        let config =
688            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
689        let stream = GraphqlStream::new(config);
690        let body = json!({ "data": null });
691        let records = stream.extract_records(&body).unwrap();
692        assert!(
693            records.is_empty(),
694            "expected empty Vec for null `data`, got {records:?}"
695        );
696    }
697
698    #[test]
699    fn extract_records_without_path_absent_data_yields_empty() {
700        // No `data` field at all → nothing to extract → empty page (matches
701        // the null-data case rather than forwarding the whole body).
702        let config =
703            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
704        let stream = GraphqlStream::new(config);
705        let body = json!({ "extensions": { "foo": 1 } });
706        let records = stream.extract_records(&body).unwrap();
707        assert!(
708            records.is_empty(),
709            "expected empty Vec when `data` is absent, got {records:?}"
710        );
711    }
712
713    #[test]
714    fn dataset_uri_returns_endpoint() {
715        use faucet_core::Source;
716        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
717            "https://api.example.com/graphql",
718            "query { id }",
719        ));
720        assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
721    }
722
723    #[test]
724    fn dataset_uri_redacts_credentials() {
725        use faucet_core::Source;
726        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
727            "https://user:pw@api.example.com/graphql",
728            "query { id }",
729        ));
730        assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
731    }
732
733    #[test]
734    fn default_retry_policy_reproduces_legacy_constants() {
735        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
736            "https://api.example.com/graphql",
737            "query { id }",
738        ));
739        assert_eq!(stream.retry_policy.max_attempts, RETRY_MAX_ATTEMPTS + 1);
740        assert_eq!(stream.retry_policy.base, RETRY_BASE_BACKOFF);
741    }
742
743    #[test]
744    fn with_retry_policy_overrides_the_default() {
745        let policy = faucet_core::RetryPolicy {
746            max_attempts: 9,
747            base: Duration::from_secs(7),
748            ..faucet_core::RetryPolicy::default()
749        };
750        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
751            "https://api.example.com/graphql",
752            "query { id }",
753        ))
754        .with_retry_policy(policy);
755        assert_eq!(stream.retry_policy.max_attempts, 9);
756        assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
757    }
758
759    #[test]
760    fn cursor_guard_detects_repeats_and_bounds_memory() {
761        let mut g = CursorGuard::new();
762        assert!(!g.is_repeat("a"));
763        assert!(!g.is_repeat("b"));
764        // Any earlier cursor repeating is a cycle, not just the adjacent one.
765        assert!(g.is_repeat("a"));
766        assert!(g.is_repeat("b"));
767
768        // Bounded: after CAP distinct cursors, the oldest is evicted, so the
769        // set never grows without bound on a legitimately large pagination.
770        let mut g = CursorGuard::new();
771        for i in 0..CursorGuard::CAP {
772            assert!(!g.is_repeat(&format!("c{i}")));
773        }
774        assert_eq!(g.order.len(), CursorGuard::CAP);
775        // One more distinct cursor evicts the oldest ("c0").
776        assert!(!g.is_repeat("overflow"));
777        assert_eq!(g.order.len(), CursorGuard::CAP);
778        assert!(!g.seen.contains_key("c0"), "oldest cursor evicted");
779        assert!(g.seen.contains_key("overflow"));
780    }
781}