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
114        loop {
115            if let Some(max) = self.config.max_pages
116                && pages_fetched >= max
117            {
118                tracing::warn!("max pages ({max}) reached");
119                break;
120            }
121
122            let body = self.execute_query(&cursor, context).await?;
123            let records = self.extract_records(&body)?;
124            all_records.extend(records);
125            pages_fetched += 1;
126
127            // Check pagination.
128            match &self.config.pagination {
129                Some(pag) => {
130                    let (step, unresolved) = decide_next_page(&body, pag, cursor.as_deref());
131                    if unresolved && !warned_unresolved_has_next {
132                        tracing::warn!(
133                            path = %pag.has_next_page_path,
134                            "GraphQL has_next_page path did not resolve to a boolean; \
135                             deferring to cursor presence to decide pagination"
136                        );
137                        warned_unresolved_has_next = true;
138                    }
139                    match step {
140                        PageStep::Stop => break,
141                        PageStep::StopLoop => {
142                            tracing::warn!("cursor loop detected, stopping pagination");
143                            break;
144                        }
145                        PageStep::Advance(next) => cursor = Some(next),
146                    }
147                }
148                None => break,
149            }
150        }
151
152        tracing::info!(
153            records = all_records.len(),
154            pages = pages_fetched,
155            "GraphQL fetch complete"
156        );
157        Ok(all_records)
158    }
159
160    /// Execute a single GraphQL query, merging parent context into variables.
161    async fn execute_query(
162        &self,
163        cursor: &Option<String>,
164        context: &std::collections::HashMap<String, Value>,
165    ) -> Result<Value, FaucetError> {
166        let mut variables = self.config.variables.clone();
167
168        // Merge parent context values into GraphQL variables.
169        if !context.is_empty()
170            && let Value::Object(ref mut map) = variables
171        {
172            for (key, value) in context {
173                map.insert(key.clone(), value.clone());
174            }
175        }
176
177        // Inject cursor and page size into variables.
178        if let (Some(pag), Some(cursor_val)) = (&self.config.pagination, cursor)
179            && let Value::Object(ref mut map) = variables
180        {
181            map.insert(pag.cursor_variable.clone(), json!(cursor_val));
182        }
183        // Inject `first:` (or whatever `page_size_variable` is named) from
184        // `batch_size`. `batch_size = 0` is the "use upstream default"
185        // sentinel — we omit the variable entirely in that case.
186        if let Some(pag) = &self.config.pagination
187            && self.config.batch_size != 0
188            && let Value::Object(map) = &mut variables
189        {
190            map.insert(
191                pag.page_size_variable.clone(),
192                json!(self.config.batch_size),
193            );
194        }
195
196        let payload = json!({
197            "query": self.config.query,
198            "variables": variables,
199        });
200
201        let mut req = self
202            .client
203            .post(&self.config.endpoint)
204            .headers(self.config.headers.clone())
205            .json(&payload);
206
207        // Resolve credentials to concrete auth. A shared auth provider (from
208        // `auth: { ref }` or injected by a library caller) takes precedence;
209        // otherwise the inline auth config is used directly.
210        let effective_auth: GraphqlAuth = if let Some(provider) = &self.auth_provider {
211            credential_to_auth(provider.credential().await?)
212        } else {
213            match &self.config.auth {
214                AuthSpec::Inline(a) => a.clone(),
215                AuthSpec::Reference(r) => {
216                    return Err(FaucetError::Auth(format!(
217                        "auth references provider '{}' but no provider was supplied; \
218                         set one via the CLI `auth:` catalog or `with_auth_provider`",
219                        r.name
220                    )));
221                }
222            }
223        };
224
225        // Apply resolved auth to the request.
226        match effective_auth {
227            GraphqlAuth::None => {}
228            GraphqlAuth::Bearer { token } => {
229                req = req.bearer_auth(token);
230            }
231            GraphqlAuth::Custom { headers } => {
232                let mut hm = reqwest::header::HeaderMap::new();
233                for (name, value) in &headers {
234                    let n =
235                        reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
236                            FaucetError::Auth(format!("invalid custom header name {name:?}: {e}"))
237                        })?;
238                    let v = reqwest::header::HeaderValue::from_str(value).map_err(|e| {
239                        FaucetError::Auth(format!("invalid custom header value for {name:?}: {e}"))
240                    })?;
241                    hm.insert(n, v);
242                }
243                req = req.headers(hm);
244            }
245        }
246
247        // Retry transient failures (5xx / connection resets) with jittered
248        // backoff, matching the REST source's reliability layer (#78/#16).
249        // GraphQL-level `errors` in a 200 body are application errors and are
250        // handled below — they are not retried here.
251        let body: Value = faucet_core::execute_with_policy(&self.retry_policy, None, || {
252            let attempt = req.try_clone();
253            async move {
254                let req = attempt.ok_or_else(|| {
255                    FaucetError::Source("graphql: request is not cloneable for retry".into())
256                })?;
257                let resp = req.send().await.map_err(FaucetError::Http)?;
258                let resp = util::check_http_response(resp, DEFAULT_ERROR_BODY_MAX_LEN).await?;
259                resp.json().await.map_err(FaucetError::Http)
260            }
261        })
262        .await?;
263
264        // Check for GraphQL-level errors.
265        if let Some(errors) = body.get("errors")
266            && let Some(arr) = errors.as_array()
267            && !arr.is_empty()
268        {
269            let msg = arr
270                .iter()
271                .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
272                .collect::<Vec<_>>()
273                .join("; ");
274            // Surface "first: must be non-null" / similar variable validation
275            // errors as `FaucetError::Config` so callers can react to the
276            // `batch_size = 0` sentinel hitting a schema that requires a
277            // non-null page-size argument. Detect by message substring —
278            // GraphQL servers don't standardise an error-code field.
279            let lower = msg.to_lowercase();
280            if self.config.batch_size == 0
281                && let Some(pag) = &self.config.pagination
282            {
283                let var_name = pag.page_size_variable.to_lowercase();
284                if lower.contains(&var_name)
285                    && (lower.contains("non-null")
286                        || lower.contains("non null")
287                        || lower.contains("must not be null")
288                        || lower.contains("cannot be null")
289                        || lower.contains("required"))
290                {
291                    return Err(FaucetError::Config(format!(
292                        "batch_size = 0 requires the upstream to accept a null {}: argument \
293                         (GraphQL errors: {msg})",
294                        pag.page_size_variable
295                    )));
296                }
297            }
298            return Err(FaucetError::HttpStatus {
299                status: 200,
300                url: self.config.endpoint.clone(),
301                body: format!("GraphQL errors: {msg}"),
302            });
303        }
304
305        Ok(body)
306    }
307
308    /// Extract records from a GraphQL response using the configured JSONPath.
309    fn extract_records(&self, body: &Value) -> Result<Vec<Value>, FaucetError> {
310        match &self.config.records_path {
311            Some(path) => util::extract_records(body, Some(path)),
312            None => {
313                // GraphQL-specific: return the `data` field as a single
314                // record. A `data` that is JSON null (or absent entirely)
315                // means there is nothing to extract — emit an empty page
316                // rather than forwarding a bogus null record to the sink
317                // (#146 LOW).
318                match body.get("data") {
319                    Some(Value::Null) | None => Ok(Vec::new()),
320                    Some(data) => Ok(vec![data.clone()]),
321                }
322            }
323        }
324    }
325
326    /// Core pagination loop yielded as a [`StreamPage`] stream.
327    ///
328    /// Each upstream GraphQL response → one [`StreamPage`]. The page size
329    /// variable in the request comes from [`GraphqlStreamConfig::batch_size`];
330    /// `batch_size = 0` omits it so the upstream uses its own default page
331    /// size and emits a single page.
332    ///
333    /// Bookmarks are always `None` — the GraphQL source has no
334    /// incremental-replication mode today. The
335    /// [`bookmark_emitted`-style trailing-checkpoint](https://github.com/PawanSikawat/faucet-stream/commit/e6fdca5)
336    /// guard from the REST source is preserved structurally so any future
337    /// incremental mode picks it up without re-deriving the pattern.
338    fn stream_pages_inner(
339        &self,
340        context: &std::collections::HashMap<String, Value>,
341    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + '_>> {
342        // Own the context so it can live inside the async-stream generator.
343        let owned_context: std::collections::HashMap<String, Value> = context.clone();
344
345        Box::pin(async_stream::try_stream! {
346            let mut cursor: Option<String> = None;
347            let mut pages_fetched = 0usize;
348            let mut warned_unresolved_has_next = false;
349            // No incremental replication today — `running_max` stays `None`.
350            // The structure mirrors the REST source so a future replication
351            // mode can plug into the same scaffolding without reworking the
352            // bookmark guard.
353            let running_max: Option<Value> = None;
354            let mut bookmark_emitted = false;
355
356            loop {
357                if let Some(max) = self.config.max_pages
358                    && pages_fetched >= max
359                {
360                    tracing::warn!("max pages ({max}) reached");
361                    break;
362                }
363
364                let body = self.execute_query(&cursor, &owned_context).await?;
365                let records = self.extract_records(&body)?;
366                pages_fetched += 1;
367
368                // Advance pagination state BEFORE yielding the current page,
369                // so the bookmark is only attached on the final page.
370                let has_next = match &self.config.pagination {
371                    Some(pag) => {
372                        let (step, unresolved) =
373                            decide_next_page(&body, pag, cursor.as_deref());
374                        if unresolved && !warned_unresolved_has_next {
375                            tracing::warn!(
376                                path = %pag.has_next_page_path,
377                                "GraphQL has_next_page path did not resolve to a boolean; \
378                                 deferring to cursor presence to decide pagination"
379                            );
380                            warned_unresolved_has_next = true;
381                        }
382                        match step {
383                            PageStep::Stop => false,
384                            PageStep::StopLoop => {
385                                tracing::warn!("cursor loop detected, stopping pagination");
386                                false
387                            }
388                            PageStep::Advance(next) => {
389                                cursor = Some(next);
390                                true
391                            }
392                        }
393                    }
394                    None => false,
395                };
396
397                if has_next {
398                    // Intermediate page — bookmark stays `None`.
399                    yield StreamPage { records, bookmark: None };
400                } else {
401                    // Final page — attach the consolidated bookmark (always
402                    // `None` until incremental mode lands).
403                    bookmark_emitted = running_max.is_some();
404                    yield StreamPage {
405                        records,
406                        bookmark: running_max.clone(),
407                    };
408                    break;
409                }
410            }
411
412            // Trailing checkpoint: if the loop exited (e.g. via `max_pages`
413            // truncation) without carrying the bookmark on a real page, emit
414            // one empty page carrying it so the pipeline persists progress.
415            // No-op today because `running_max` is always `None`, but kept so
416            // a future incremental mode inherits the guard from the REST
417            // source's regression fix (commit e6fdca5).
418            if !bookmark_emitted && running_max.is_some() {
419                yield StreamPage {
420                    records: Vec::new(),
421                    bookmark: running_max,
422                };
423            }
424
425            tracing::info!(
426                pages = pages_fetched,
427                batch_size = self.config.batch_size,
428                "GraphQL source stream complete",
429            );
430        })
431    }
432}
433
434#[async_trait]
435impl faucet_core::Source for GraphqlStream {
436    async fn fetch_with_context(
437        &self,
438        context: &std::collections::HashMap<String, serde_json::Value>,
439    ) -> Result<Vec<Value>, FaucetError> {
440        self.fetch_all_with_context(context).await
441    }
442
443    /// Stream GraphQL responses page-by-page without buffering the full
444    /// result set. The trait-level `batch_size` argument is ignored in
445    /// favour of [`GraphqlStreamConfig::batch_size`] — the config field is
446    /// the user-facing knob the README documents, and routing the
447    /// pipeline-supplied hint through it would silently override an
448    /// explicit config value.
449    fn stream_pages<'a>(
450        &'a self,
451        context: &'a std::collections::HashMap<String, Value>,
452        _batch_size: usize,
453    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
454        self.stream_pages_inner(context)
455    }
456
457    fn config_schema(&self) -> serde_json::Value {
458        serde_json::to_value(faucet_core::schema_for!(GraphqlStreamConfig))
459            .expect("schema serialization")
460    }
461
462    fn dataset_uri(&self) -> String {
463        faucet_core::redact_uri_credentials(&self.config.endpoint)
464    }
465}
466
467fn extract_string(body: &Value, path: &str) -> Option<String> {
468    let results = body.query(path).ok()?;
469    match results.first()? {
470        Value::String(s) => Some(s.clone()),
471        _ => None,
472    }
473}
474
475fn extract_bool(body: &Value, path: &str) -> Option<bool> {
476    let results = body.query(path).ok()?;
477    results.first()?.as_bool()
478}
479
480/// What to do after fetching a page.
481#[derive(Debug, PartialEq)]
482enum PageStep {
483    /// No further pages (has-next is `false`, or there is no next cursor).
484    Stop,
485    /// The server returned the cursor we just used — advancing would re-fetch
486    /// the same page. Caller warns and stops.
487    StopLoop,
488    /// Fetch another page with this cursor.
489    Advance(String),
490}
491
492/// Pure pagination-advance decision shared by the eager and streaming paths.
493///
494/// `prev_cursor` is the cursor just used (for loop detection). The returned
495/// bool is `true` when the configured `has_next_page_path` did **not** resolve
496/// to a boolean: that is treated as "can't tell" and we **defer to cursor
497/// presence** rather than silently stopping — an unmatched has-next path must
498/// not drop the remaining pages of a paginated result (F52). The caller warns
499/// once on that condition.
500fn decide_next_page(
501    body: &Value,
502    pag: &GraphqlPagination,
503    prev_cursor: Option<&str>,
504) -> (PageStep, bool) {
505    let (stop, unresolved) = match extract_bool(body, &pag.has_next_page_path) {
506        Some(false) => (true, false),
507        Some(true) => (false, false),
508        // Path absent / not a boolean: defer the decision to the cursor signal.
509        None => (false, true),
510    };
511    if stop {
512        return (PageStep::Stop, unresolved);
513    }
514    match extract_string(body, &pag.cursor_path) {
515        None => (PageStep::Stop, unresolved),
516        Some(next) if Some(next.as_str()) == prev_cursor => (PageStep::StopLoop, unresolved),
517        Some(next) => (PageStep::Advance(next), unresolved),
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn extract_string_from_json() {
527        let body = json!({"data": {"users": {"pageInfo": {"endCursor": "abc123"}}}});
528        assert_eq!(
529            extract_string(&body, "$.data.users.pageInfo.endCursor"),
530            Some("abc123".into())
531        );
532    }
533
534    #[test]
535    fn extract_bool_from_json() {
536        let body = json!({"data": {"users": {"pageInfo": {"hasNextPage": true}}}});
537        assert_eq!(
538            extract_bool(&body, "$.data.users.pageInfo.hasNextPage"),
539            Some(true)
540        );
541    }
542
543    fn pageinfo_pagination() -> GraphqlPagination {
544        GraphqlPagination {
545            has_next_page_path: "$.data.users.pageInfo.hasNextPage".into(),
546            cursor_path: "$.data.users.pageInfo.endCursor".into(),
547            ..GraphqlPagination::default()
548        }
549    }
550
551    #[test]
552    fn decide_next_page_advances_when_has_next_true() {
553        let body =
554            json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c2"}}}});
555        let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
556        assert_eq!(step, PageStep::Advance("c2".into()));
557        assert!(!unresolved);
558    }
559
560    #[test]
561    fn decide_next_page_stops_when_has_next_false() {
562        let body =
563            json!({"data": {"users": {"pageInfo": {"hasNextPage": false, "endCursor": "c2"}}}});
564        let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
565        assert_eq!(step, PageStep::Stop);
566        assert!(!unresolved);
567    }
568
569    #[test]
570    fn decide_next_page_detects_cursor_loop() {
571        let body =
572            json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c1"}}}});
573        let (step, _) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
574        assert_eq!(step, PageStep::StopLoop);
575    }
576
577    #[test]
578    fn decide_next_page_defers_to_cursor_when_has_next_unresolved() {
579        // F52: an absent / non-boolean has-next path must NOT silently stop
580        // pagination. With a valid distinct next cursor we keep going, and the
581        // unresolved flag is raised so the caller warns once.
582        let body = json!({"data": {"users": {"pageInfo": {"endCursor": "c2"}}}}); // no hasNextPage
583        let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
584        assert_eq!(
585            step,
586            PageStep::Advance("c2".into()),
587            "unresolved has-next must defer to cursor presence, not stop"
588        );
589        assert!(unresolved, "the caller is told to warn once");
590
591        // Unresolved has-next AND no cursor → genuinely stop (nothing to follow).
592        let body_no_cursor = json!({"data": {"users": {"pageInfo": {}}}});
593        let (step, unresolved) =
594            decide_next_page(&body_no_cursor, &pageinfo_pagination(), Some("c1"));
595        assert_eq!(step, PageStep::Stop);
596        assert!(unresolved);
597    }
598
599    #[test]
600    fn extract_records_with_path() {
601        let config =
602            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { users { id } }")
603                .records_path("$.data.users[*]");
604        let stream = GraphqlStream::new(config);
605        let body = json!({"data": {"users": [{"id": 1}, {"id": 2}]}});
606        let records = stream.extract_records(&body).unwrap();
607        assert_eq!(records.len(), 2);
608        assert_eq!(records[0]["id"], 1);
609    }
610
611    #[test]
612    fn extract_records_without_path_returns_data() {
613        let config =
614            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
615        let stream = GraphqlStream::new(config);
616        let body = json!({"data": {"user": {"id": 1}}});
617        let records = stream.extract_records(&body).unwrap();
618        assert_eq!(records.len(), 1);
619        assert_eq!(records[0]["user"]["id"], 1);
620    }
621
622    #[test]
623    fn extract_records_without_path_null_data_yields_empty() {
624        // A response of `{"data": null}` must NOT emit a bogus null record:
625        // `data` being JSON null means there is nothing to extract, so the
626        // page is empty (#146 LOW).
627        let config =
628            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
629        let stream = GraphqlStream::new(config);
630        let body = json!({ "data": null });
631        let records = stream.extract_records(&body).unwrap();
632        assert!(
633            records.is_empty(),
634            "expected empty Vec for null `data`, got {records:?}"
635        );
636    }
637
638    #[test]
639    fn extract_records_without_path_absent_data_yields_empty() {
640        // No `data` field at all → nothing to extract → empty page (matches
641        // the null-data case rather than forwarding the whole body).
642        let config =
643            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
644        let stream = GraphqlStream::new(config);
645        let body = json!({ "extensions": { "foo": 1 } });
646        let records = stream.extract_records(&body).unwrap();
647        assert!(
648            records.is_empty(),
649            "expected empty Vec when `data` is absent, got {records:?}"
650        );
651    }
652
653    #[test]
654    fn dataset_uri_returns_endpoint() {
655        use faucet_core::Source;
656        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
657            "https://api.example.com/graphql",
658            "query { id }",
659        ));
660        assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
661    }
662
663    #[test]
664    fn dataset_uri_redacts_credentials() {
665        use faucet_core::Source;
666        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
667            "https://user:pw@api.example.com/graphql",
668            "query { id }",
669        ));
670        assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
671    }
672
673    #[test]
674    fn default_retry_policy_reproduces_legacy_constants() {
675        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
676            "https://api.example.com/graphql",
677            "query { id }",
678        ));
679        assert_eq!(stream.retry_policy.max_attempts, RETRY_MAX_ATTEMPTS + 1);
680        assert_eq!(stream.retry_policy.base, RETRY_BASE_BACKOFF);
681    }
682
683    #[test]
684    fn with_retry_policy_overrides_the_default() {
685        let policy = faucet_core::RetryPolicy {
686            max_attempts: 9,
687            base: Duration::from_secs(7),
688            ..faucet_core::RetryPolicy::default()
689        };
690        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
691            "https://api.example.com/graphql",
692            "query { id }",
693        ))
694        .with_retry_policy(policy);
695        assert_eq!(stream.retry_policy.max_attempts, 9);
696        assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
697    }
698}