Skip to main content

faucet_source_graphql/
stream.rs

1//! GraphQL stream executor.
2
3use crate::config::{
4    GraphqlAuth, GraphqlOffsetPagination, GraphqlPagination, GraphqlPaginationSpec,
5    GraphqlStreamConfig,
6};
7use async_trait::async_trait;
8use base64::Engine as _;
9use faucet_core::util::{self, DEFAULT_ERROR_BODY_MAX_LEN};
10use faucet_core::{AuthSpec, Credential, FaucetError, SharedAuthProvider, Stream, StreamPage};
11use jsonpath_rust::JsonPath;
12use reqwest::Client;
13use serde_json::{Value, json};
14use std::collections::HashMap;
15use std::pin::Pin;
16use std::time::Duration;
17
18/// Retries on transient (5xx / connection) failures before giving up.
19const RETRY_MAX_ATTEMPTS: u32 = 3;
20/// Base exponential-backoff delay between retries.
21const RETRY_BASE_BACKOFF: Duration = Duration::from_millis(500);
22
23/// A configured GraphQL source that handles pagination and extraction.
24pub struct GraphqlStream {
25    config: GraphqlStreamConfig,
26    client: Client,
27    /// Optional shared auth provider. When set, it takes precedence over inline
28    /// auth. Used by the CLI to resolve `auth: { ref }`, and by library callers
29    /// who construct one provider and inject it into many sources.
30    auth_provider: Option<SharedAuthProvider>,
31    /// Retry policy for transient request failures. Defaulted in `new()` to
32    /// reproduce the legacy `RETRY_MAX_ATTEMPTS` / `RETRY_BASE_BACKOFF`
33    /// constants; overridable via [`with_retry_policy`](Self::with_retry_policy).
34    retry_policy: faucet_core::RetryPolicy,
35}
36
37/// Attach a mutual-TLS client identity to the HTTP client builder (#495). Only
38/// compiled with the `mtls` feature; the stub errors so a `tls:` block on a
39/// build without the feature fails loudly instead of silently sending no cert.
40#[cfg(feature = "mtls")]
41fn apply_client_tls(
42    builder: reqwest::ClientBuilder,
43    tls: &faucet_core::TlsClientConfig,
44) -> Result<reqwest::ClientBuilder, FaucetError> {
45    let identity = build_identity(tls)?;
46    let mut builder = builder.identity(identity).use_native_tls();
47    if let Some(v) = &tls.min_version {
48        // `TlsClientConfig::validate` guarantees `v` is "1.2" or "1.3".
49        let version = if v == "1.3" {
50            reqwest::tls::Version::TLS_1_3
51        } else {
52            reqwest::tls::Version::TLS_1_2
53        };
54        builder = builder.min_tls_version(version);
55    }
56    Ok(builder)
57}
58
59#[cfg(not(feature = "mtls"))]
60fn apply_client_tls(
61    _builder: reqwest::ClientBuilder,
62    _tls: &faucet_core::TlsClientConfig,
63) -> Result<reqwest::ClientBuilder, FaucetError> {
64    Err(FaucetError::Config(
65        "a `tls:` (mutual-TLS) block is configured, but this build of \
66         faucet-source-graphql lacks the `mtls` feature; rebuild with `--features mtls`"
67            .into(),
68    ))
69}
70
71/// Build a [`reqwest::Identity`] from the PEM pair or the PKCS#12 file. Errors
72/// never echo key material — only the backend's opaque parse message.
73#[cfg(feature = "mtls")]
74fn build_identity(tls: &faucet_core::TlsClientConfig) -> Result<reqwest::Identity, FaucetError> {
75    if let Some(p12_path) = &tls.client_identity_pkcs12 {
76        let der = std::fs::read(p12_path).map_err(|e| {
77            FaucetError::Config(format!(
78                "tls: could not read PKCS#12 file {p12_path:?}: {e}"
79            ))
80        })?;
81        let password = tls.pkcs12_password.as_deref().unwrap_or("");
82        reqwest::Identity::from_pkcs12_der(&der, password)
83            .map_err(|e| FaucetError::Config(format!("tls: invalid PKCS#12 identity: {e}")))
84    } else {
85        let cert = tls.client_cert.as_deref().unwrap_or_default();
86        let key = tls.client_key.as_deref().unwrap_or_default();
87        reqwest::Identity::from_pkcs8_pem(cert.as_bytes(), key.as_bytes())
88            .map_err(|e| FaucetError::Config(format!("tls: invalid PEM client identity: {e}")))
89    }
90}
91
92/// Map a [`Credential`] from a shared provider onto the GraphQL [`GraphqlAuth`]
93/// representation so the existing header-application path can be reused.
94fn credential_to_auth(cred: Credential) -> GraphqlAuth {
95    match cred {
96        Credential::Bearer(token) => GraphqlAuth::Bearer { token },
97        Credential::Token(token) => GraphqlAuth::Custom {
98            headers: HashMap::from([("Authorization".into(), token)]),
99        },
100        Credential::Header { name, value } => GraphqlAuth::Custom {
101            headers: HashMap::from([(name, value)]),
102        },
103        Credential::Basic { username, password } => GraphqlAuth::Custom {
104            headers: HashMap::from([(
105                "Authorization".into(),
106                format!(
107                    "Basic {}",
108                    base64::engine::general_purpose::STANDARD
109                        .encode(format!("{username}:{password}"))
110                ),
111            )]),
112        },
113    }
114}
115
116impl GraphqlStream {
117    /// Create a new GraphQL stream from the given configuration.
118    ///
119    /// Infallible for the common case. Prefer [`try_new`](Self::try_new) when the
120    /// config may carry a `tls:` (mutual-TLS) block: this panics if the client
121    /// (or the TLS identity) fails to build, matching the pre-existing
122    /// `Client::new()` behavior.
123    pub fn new(config: GraphqlStreamConfig) -> Self {
124        Self::try_new(config).expect(
125            "GraphqlStream::new: client build failed; use try_new() for fallible construction",
126        )
127    }
128
129    /// Fallible constructor — builds the HTTP client, including any mutual-TLS
130    /// client identity. The CLI registry uses this (after `config.validate()`) so
131    /// a bad `tls:` block surfaces as a typed error instead of a panic. Only the
132    /// `tls:` block is validated here; the registry still calls
133    /// [`GraphqlStreamConfig::validate`] for the rest, keeping `new()` infallible
134    /// for non-TLS configs exactly as before.
135    pub fn try_new(config: GraphqlStreamConfig) -> Result<Self, FaucetError> {
136        let mut builder = Client::builder();
137        if let Some(tls) = &config.tls {
138            tls.validate()?;
139            builder = apply_client_tls(builder, tls)?;
140        }
141        let client = builder.build().map_err(|e| {
142            FaucetError::Config(format!("graphql: failed to build HTTP client: {e}"))
143        })?;
144        Ok(Self {
145            config,
146            client,
147            auth_provider: None,
148            // Reproduce the legacy `execute_with_retry(RETRY_MAX_ATTEMPTS,
149            // RETRY_BASE_BACKOFF, …)` behavior exactly: `max_retries` is
150            // retries-after-first, so `max_attempts = RETRY_MAX_ATTEMPTS + 1`.
151            retry_policy: faucet_core::RetryPolicy {
152                max_attempts: RETRY_MAX_ATTEMPTS + 1,
153                backoff: faucet_core::BackoffKind::Exponential,
154                base: RETRY_BASE_BACKOFF,
155                max: Duration::from_secs(60),
156                jitter: true,
157                retry_on: faucet_core::RetryClassSet::default(),
158            },
159        })
160    }
161
162    /// Attach a custom [`RetryPolicy`](faucet_core::RetryPolicy) for transient
163    /// request failures, replacing the default derived from
164    /// `RETRY_MAX_ATTEMPTS` / `RETRY_BASE_BACKOFF`. Used by the CLI to inject a
165    /// pipeline-level `resilience:` policy into the source.
166    pub fn with_retry_policy(mut self, policy: faucet_core::RetryPolicy) -> Self {
167        self.retry_policy = policy;
168        self
169    }
170
171    /// Attach a shared [`AuthProvider`](faucet_core::AuthProvider). When set, the
172    /// provider supplies the credential for every request (taking precedence
173    /// over inline auth), so several sources can share one token with
174    /// single-flight refresh. Used by the CLI to resolve `auth: { ref }`, and by
175    /// library callers who construct one provider and inject it into many sources.
176    pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
177        self.auth_provider = Some(provider);
178        self
179    }
180
181    /// Fetch all records across all pages.
182    pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
183        self.fetch_all_with_context(&std::collections::HashMap::new())
184            .await
185    }
186
187    /// Fetch all records, merging parent context values into GraphQL variables.
188    async fn fetch_all_with_context(
189        &self,
190        context: &std::collections::HashMap<String, Value>,
191    ) -> Result<Vec<Value>, FaucetError> {
192        let mut all_records = Vec::new();
193        let mut cursor: Option<String> = None;
194        let mut offset = 0usize;
195        let mut pages_fetched = 0usize;
196        let mut warned_unresolved_has_next = false;
197        let mut cursor_guard = CursorGuard::new();
198
199        loop {
200            if let Some(max) = self.config.max_pages
201                && pages_fetched >= max
202            {
203                tracing::warn!("max pages ({max}) reached");
204                break;
205            }
206
207            let body = self.execute_query(&cursor, offset, context).await?;
208            let records = self.extract_records(&body)?;
209            let records_in_page = records.len();
210            all_records.extend(records);
211            pages_fetched += 1;
212
213            // Check pagination.
214            match &self.config.pagination {
215                Some(GraphqlPaginationSpec::Cursor(pag)) => {
216                    let (step, unresolved) = decide_next_page(&body, pag, cursor.as_deref());
217                    if unresolved && !warned_unresolved_has_next {
218                        tracing::warn!(
219                            path = %pag.has_next_page_path,
220                            "GraphQL has_next_page path did not resolve to a boolean; \
221                             deferring to cursor presence to decide pagination"
222                        );
223                        warned_unresolved_has_next = true;
224                    }
225                    match step {
226                        PageStep::Stop => break,
227                        PageStep::StopLoop => {
228                            tracing::warn!("cursor loop detected, stopping pagination");
229                            break;
230                        }
231                        PageStep::Advance(next) => {
232                            if cursor_guard.is_repeat(&next) {
233                                tracing::warn!(
234                                    "cursor cycle detected (cursor already seen), stopping pagination"
235                                );
236                                break;
237                            }
238                            cursor = Some(next);
239                        }
240                    }
241                }
242                Some(GraphqlPaginationSpec::Offset(off)) => {
243                    if offset_should_continue(records_in_page, off) {
244                        offset += off.page_size;
245                    } else {
246                        break;
247                    }
248                }
249                None => break,
250            }
251        }
252
253        tracing::info!(
254            records = all_records.len(),
255            pages = pages_fetched,
256            "GraphQL fetch complete"
257        );
258        Ok(all_records)
259    }
260
261    /// Execute a single GraphQL query, merging parent context into variables.
262    ///
263    /// `cursor` carries the Relay cursor for the next request (cursor mode);
264    /// `offset` carries the current offset (offset mode). Only the field the
265    /// active pagination style uses is injected — the other stays inert.
266    async fn execute_query(
267        &self,
268        cursor: &Option<String>,
269        offset: usize,
270        context: &std::collections::HashMap<String, Value>,
271    ) -> Result<Value, FaucetError> {
272        let mut variables = self.config.variables.clone();
273
274        // Merge parent context values into GraphQL variables.
275        if !context.is_empty()
276            && let Value::Object(ref mut map) = variables
277        {
278            for (key, value) in context {
279                map.insert(key.clone(), value.clone());
280            }
281        }
282
283        // Inject the per-request pagination variable(s).
284        match &self.config.pagination {
285            // Cursor mode: inject the `after` cursor (once we have one) and the
286            // page-size variable from `batch_size`. `batch_size = 0` is the
287            // "use upstream default" sentinel — we omit the size variable.
288            Some(GraphqlPaginationSpec::Cursor(pag)) => {
289                if let (Some(cursor_val), Value::Object(map)) = (cursor, &mut variables) {
290                    map.insert(pag.cursor_variable.clone(), json!(cursor_val));
291                }
292                if self.config.batch_size != 0
293                    && let Value::Object(map) = &mut variables
294                {
295                    map.insert(
296                        pag.page_size_variable.clone(),
297                        json!(self.config.batch_size),
298                    );
299                }
300            }
301            // Offset mode: inject the current offset as a JSON number. The page
302            // size is not injected — the user bakes the limit into the query.
303            Some(GraphqlPaginationSpec::Offset(off)) => {
304                if let Value::Object(map) = &mut variables {
305                    map.insert(off.offset_variable.clone(), json!(offset));
306                }
307            }
308            None => {}
309        }
310
311        let payload = json!({
312            "query": self.config.query,
313            "variables": variables,
314        });
315
316        let mut req = self
317            .client
318            .post(&self.config.endpoint)
319            .headers(self.config.headers.clone())
320            .json(&payload);
321
322        // Resolve credentials to concrete auth. A shared auth provider (from
323        // `auth: { ref }` or injected by a library caller) takes precedence;
324        // otherwise the inline auth config is used directly.
325        let effective_auth: GraphqlAuth = if let Some(provider) = &self.auth_provider {
326            credential_to_auth(provider.credential().await?)
327        } else {
328            match &self.config.auth {
329                AuthSpec::Inline(a) => a.clone(),
330                AuthSpec::Reference(r) => {
331                    return Err(FaucetError::Auth(format!(
332                        "auth references provider '{}' but no provider was supplied; \
333                         set one via the CLI `auth:` catalog or `with_auth_provider`",
334                        r.name
335                    )));
336                }
337            }
338        };
339
340        // Apply resolved auth to the request.
341        match effective_auth {
342            GraphqlAuth::None => {}
343            GraphqlAuth::Bearer { token } => {
344                req = req.bearer_auth(token);
345            }
346            GraphqlAuth::Custom { headers } => {
347                let mut hm = reqwest::header::HeaderMap::new();
348                for (name, value) in &headers {
349                    let n =
350                        reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
351                            FaucetError::Auth(format!("invalid custom header name {name:?}: {e}"))
352                        })?;
353                    let v = reqwest::header::HeaderValue::from_str(value).map_err(|e| {
354                        FaucetError::Auth(format!("invalid custom header value for {name:?}: {e}"))
355                    })?;
356                    hm.insert(n, v);
357                }
358                req = req.headers(hm);
359            }
360        }
361
362        // Retry transient failures (5xx / connection resets) with jittered
363        // backoff, matching the REST source's reliability layer (#78/#16).
364        // GraphQL-level `errors` in a 200 body are application errors and are
365        // handled below — they are not retried here.
366        let body: Value = faucet_core::execute_with_policy(&self.retry_policy, None, || {
367            let attempt = req.try_clone();
368            async move {
369                let req = attempt.ok_or_else(|| {
370                    FaucetError::Source("graphql: request is not cloneable for retry".into())
371                })?;
372                let resp = req.send().await.map_err(FaucetError::Http)?;
373                let resp = util::check_http_response(resp, DEFAULT_ERROR_BODY_MAX_LEN).await?;
374                resp.json().await.map_err(FaucetError::Http)
375            }
376        })
377        .await?;
378
379        // Check for GraphQL-level errors.
380        if let Some(errors) = body.get("errors")
381            && let Some(arr) = errors.as_array()
382            && !arr.is_empty()
383        {
384            let msg = arr
385                .iter()
386                .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
387                .collect::<Vec<_>>()
388                .join("; ");
389            // Surface "first: must be non-null" / similar variable validation
390            // errors as `FaucetError::Config` so callers can react to the
391            // `batch_size = 0` sentinel hitting a schema that requires a
392            // non-null page-size argument. Detect by message substring —
393            // GraphQL servers don't standardise an error-code field.
394            let lower = msg.to_lowercase();
395            if self.config.batch_size == 0
396                && let Some(GraphqlPaginationSpec::Cursor(pag)) = &self.config.pagination
397            {
398                let var_name = pag.page_size_variable.to_lowercase();
399                if lower.contains(&var_name)
400                    && (lower.contains("non-null")
401                        || lower.contains("non null")
402                        || lower.contains("must not be null")
403                        || lower.contains("cannot be null")
404                        || lower.contains("required"))
405                {
406                    return Err(FaucetError::Config(format!(
407                        "batch_size = 0 requires the upstream to accept a null {}: argument \
408                         (GraphQL errors: {msg})",
409                        pag.page_size_variable
410                    )));
411                }
412            }
413            return Err(FaucetError::HttpStatus {
414                status: 200,
415                url: self.config.endpoint.clone(),
416                body: format!("GraphQL errors: {msg}"),
417            });
418        }
419
420        Ok(body)
421    }
422
423    /// Extract records from a GraphQL response using the configured JSONPath.
424    fn extract_records(&self, body: &Value) -> Result<Vec<Value>, FaucetError> {
425        match &self.config.records_path {
426            Some(path) => util::extract_records(body, Some(path)),
427            None => {
428                // GraphQL-specific: return the `data` field as a single
429                // record. A `data` that is JSON null (or absent entirely)
430                // means there is nothing to extract — emit an empty page
431                // rather than forwarding a bogus null record to the sink
432                // (#146 LOW).
433                match body.get("data") {
434                    Some(Value::Null) | None => Ok(Vec::new()),
435                    Some(data) => Ok(vec![data.clone()]),
436                }
437            }
438        }
439    }
440
441    /// Core pagination loop yielded as a [`StreamPage`] stream.
442    ///
443    /// Each upstream GraphQL response → one [`StreamPage`]. The page size
444    /// variable in the request comes from [`GraphqlStreamConfig::batch_size`];
445    /// `batch_size = 0` omits it so the upstream uses its own default page
446    /// size and emits a single page.
447    ///
448    /// Bookmarks are always `None` — the GraphQL source has no
449    /// incremental-replication mode today. The
450    /// [`bookmark_emitted`-style trailing-checkpoint](https://github.com/faucet-hq/faucet-stream/commit/e6fdca5)
451    /// guard from the REST source is preserved structurally so any future
452    /// incremental mode picks it up without re-deriving the pattern.
453    fn stream_pages_inner(
454        &self,
455        context: &std::collections::HashMap<String, Value>,
456    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + '_>> {
457        // Own the context so it can live inside the async-stream generator.
458        let owned_context: std::collections::HashMap<String, Value> = context.clone();
459
460        Box::pin(async_stream::try_stream! {
461            let mut cursor: Option<String> = None;
462            let mut offset = 0usize;
463            let mut cursor_guard = CursorGuard::new();
464            let mut pages_fetched = 0usize;
465            let mut warned_unresolved_has_next = false;
466            // No incremental replication today — `running_max` stays `None`.
467            // The structure mirrors the REST source so a future replication
468            // mode can plug into the same scaffolding without reworking the
469            // bookmark guard.
470            let running_max: Option<Value> = None;
471            let mut bookmark_emitted = false;
472
473            loop {
474                if let Some(max) = self.config.max_pages
475                    && pages_fetched >= max
476                {
477                    tracing::warn!("max pages ({max}) reached");
478                    break;
479                }
480
481                let body = self.execute_query(&cursor, offset, &owned_context).await?;
482                let records = self.extract_records(&body)?;
483                let records_in_page = records.len();
484                pages_fetched += 1;
485
486                // Advance pagination state BEFORE yielding the current page,
487                // so the bookmark is only attached on the final page.
488                let has_next = match &self.config.pagination {
489                    Some(GraphqlPaginationSpec::Cursor(pag)) => {
490                        let (step, unresolved) =
491                            decide_next_page(&body, pag, cursor.as_deref());
492                        if unresolved && !warned_unresolved_has_next {
493                            tracing::warn!(
494                                path = %pag.has_next_page_path,
495                                "GraphQL has_next_page path did not resolve to a boolean; \
496                                 deferring to cursor presence to decide pagination"
497                            );
498                            warned_unresolved_has_next = true;
499                        }
500                        match step {
501                            PageStep::Stop => false,
502                            PageStep::StopLoop => {
503                                tracing::warn!("cursor loop detected, stopping pagination");
504                                false
505                            }
506                            PageStep::Advance(next) => {
507                                if cursor_guard.is_repeat(&next) {
508                                    tracing::warn!(
509                                        "cursor cycle detected (cursor already seen), stopping pagination"
510                                    );
511                                    false
512                                } else {
513                                    cursor = Some(next);
514                                    true
515                                }
516                            }
517                        }
518                    }
519                    Some(GraphqlPaginationSpec::Offset(off)) => {
520                        let advance = offset_should_continue(records_in_page, off);
521                        if advance {
522                            offset += off.page_size;
523                        }
524                        advance
525                    }
526                    None => false,
527                };
528
529                if has_next {
530                    // Intermediate page — bookmark stays `None`.
531                    yield StreamPage { records, bookmark: None };
532                } else {
533                    // Final page — attach the consolidated bookmark (always
534                    // `None` until incremental mode lands).
535                    bookmark_emitted = running_max.is_some();
536                    yield StreamPage {
537                        records,
538                        bookmark: running_max.clone(),
539                    };
540                    break;
541                }
542            }
543
544            // Trailing checkpoint: if the loop exited (e.g. via `max_pages`
545            // truncation) without carrying the bookmark on a real page, emit
546            // one empty page carrying it so the pipeline persists progress.
547            // No-op today because `running_max` is always `None`, but kept so
548            // a future incremental mode inherits the guard from the REST
549            // source's regression fix (commit e6fdca5).
550            if !bookmark_emitted && running_max.is_some() {
551                yield StreamPage {
552                    records: Vec::new(),
553                    bookmark: running_max,
554                };
555            }
556
557            tracing::info!(
558                pages = pages_fetched,
559                batch_size = self.config.batch_size,
560                "GraphQL source stream complete",
561            );
562        })
563    }
564}
565
566#[async_trait]
567impl faucet_core::Source for GraphqlStream {
568    async fn fetch_with_context(
569        &self,
570        context: &std::collections::HashMap<String, serde_json::Value>,
571    ) -> Result<Vec<Value>, FaucetError> {
572        self.fetch_all_with_context(context).await
573    }
574
575    /// Stream GraphQL responses page-by-page without buffering the full
576    /// result set. The trait-level `batch_size` argument is ignored in
577    /// favour of [`GraphqlStreamConfig::batch_size`] — the config field is
578    /// the user-facing knob the README documents, and routing the
579    /// pipeline-supplied hint through it would silently override an
580    /// explicit config value.
581    fn stream_pages<'a>(
582        &'a self,
583        context: &'a std::collections::HashMap<String, Value>,
584        _batch_size: usize,
585    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
586        self.stream_pages_inner(context)
587    }
588
589    fn connector_name(&self) -> &'static str {
590        "graphql"
591    }
592
593    fn config_schema(&self) -> serde_json::Value {
594        serde_json::to_value(faucet_core::schema_for!(GraphqlStreamConfig))
595            .expect("schema serialization")
596    }
597
598    fn dataset_uri(&self) -> String {
599        faucet_core::redact_uri_credentials(&self.config.endpoint)
600    }
601}
602
603fn extract_string(body: &Value, path: &str) -> Option<String> {
604    let results = body.query(path).ok()?;
605    match results.first()? {
606        Value::String(s) => Some(s.clone()),
607        _ => None,
608    }
609}
610
611fn extract_bool(body: &Value, path: &str) -> Option<bool> {
612    let results = body.query(path).ok()?;
613    results.first()?.as_bool()
614}
615
616/// What to do after fetching a page.
617#[derive(Debug, PartialEq)]
618enum PageStep {
619    /// No further pages (has-next is `false`, or there is no next cursor).
620    Stop,
621    /// The server returned the cursor we just used — advancing would re-fetch
622    /// the same page. Caller warns and stops.
623    StopLoop,
624    /// Fetch another page with this cursor.
625    Advance(String),
626}
627
628/// Pure pagination-advance decision shared by the eager and streaming paths.
629///
630/// `prev_cursor` is the cursor just used (for loop detection). The returned
631/// bool is `true` when the configured `has_next_page_path` did **not** resolve
632/// to a boolean: that is treated as "can't tell" and we **defer to cursor
633/// presence** rather than silently stopping — an unmatched has-next path must
634/// not drop the remaining pages of a paginated result (F52). The caller warns
635/// once on that condition.
636fn decide_next_page(
637    body: &Value,
638    pag: &GraphqlPagination,
639    prev_cursor: Option<&str>,
640) -> (PageStep, bool) {
641    let (stop, unresolved) = match extract_bool(body, &pag.has_next_page_path) {
642        Some(false) => (true, false),
643        Some(true) => (false, false),
644        // Path absent / not a boolean: defer the decision to the cursor signal.
645        None => (false, true),
646    };
647    if stop {
648        return (PageStep::Stop, unresolved);
649    }
650    match extract_string(body, &pag.cursor_path) {
651        None => (PageStep::Stop, unresolved),
652        Some(next) if Some(next.as_str()) == prev_cursor => (PageStep::StopLoop, unresolved),
653        Some(next) => (PageStep::Advance(next), unresolved),
654    }
655}
656
657/// Pure offset-pagination advance decision.
658///
659/// Returns `true` when another page should be fetched (the caller then advances
660/// the offset by `page_size`), `false` to stop. Termination rules:
661///
662/// - A **fully empty** page (0 records) always stops — this is the unconditional
663///   loop guard that keeps `stop_when_short: false` from paginating forever.
664/// - With `stop_when_short` (the default), a **short** page — fewer than
665///   `page_size` records — is the last one and stops pagination.
666/// - Otherwise (a full page, or `stop_when_short: false` with a non-empty page)
667///   pagination continues.
668fn offset_should_continue(records_in_page: usize, off: &GraphqlOffsetPagination) -> bool {
669    if records_in_page == 0 {
670        return false;
671    }
672    if off.stop_when_short && records_in_page < off.page_size {
673        return false;
674    }
675    true
676}
677
678/// Bounded record of recently-advanced pagination cursors.
679///
680/// [`decide_next_page`] only compares against the *immediately previous* cursor,
681/// so a server that returns `hasNextPage: true` while cycling its cursor across
682/// two or more values (`c1→c2→c1→c2…`) would never trip that guard and — with
683/// `max_pages` unset — paginate forever, re-emitting the same pages (#466 M2).
684/// This catches any such cycle by remembering the cursors already seen.
685///
686/// Bounded to [`Self::CAP`] entries so the streaming path keeps its O(page)
687/// memory guarantee on a legitimately large result set (whose cursors are all
688/// distinct, so eviction never causes a false positive). A cycle length beyond
689/// the cap is not realistic for a real endpoint.
690struct CursorGuard {
691    seen: HashMap<String, ()>,
692    order: std::collections::VecDeque<String>,
693}
694
695impl CursorGuard {
696    const CAP: usize = 4096;
697
698    fn new() -> Self {
699        Self {
700            seen: HashMap::new(),
701            order: std::collections::VecDeque::new(),
702        }
703    }
704
705    /// Record `cursor`; return `true` if it had already been seen (a cycle).
706    fn is_repeat(&mut self, cursor: &str) -> bool {
707        if self.seen.contains_key(cursor) {
708            return true;
709        }
710        if self.order.len() >= Self::CAP
711            && let Some(old) = self.order.pop_front()
712        {
713            self.seen.remove(&old);
714        }
715        self.seen.insert(cursor.to_string(), ());
716        self.order.push_back(cursor.to_string());
717        false
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    #[test]
726    fn extract_string_from_json() {
727        let body = json!({"data": {"users": {"pageInfo": {"endCursor": "abc123"}}}});
728        assert_eq!(
729            extract_string(&body, "$.data.users.pageInfo.endCursor"),
730            Some("abc123".into())
731        );
732    }
733
734    #[test]
735    fn extract_bool_from_json() {
736        let body = json!({"data": {"users": {"pageInfo": {"hasNextPage": true}}}});
737        assert_eq!(
738            extract_bool(&body, "$.data.users.pageInfo.hasNextPage"),
739            Some(true)
740        );
741    }
742
743    fn pageinfo_pagination() -> GraphqlPagination {
744        GraphqlPagination {
745            has_next_page_path: "$.data.users.pageInfo.hasNextPage".into(),
746            cursor_path: "$.data.users.pageInfo.endCursor".into(),
747            ..GraphqlPagination::default()
748        }
749    }
750
751    #[test]
752    fn decide_next_page_advances_when_has_next_true() {
753        let body =
754            json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c2"}}}});
755        let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
756        assert_eq!(step, PageStep::Advance("c2".into()));
757        assert!(!unresolved);
758    }
759
760    #[test]
761    fn decide_next_page_stops_when_has_next_false() {
762        let body =
763            json!({"data": {"users": {"pageInfo": {"hasNextPage": false, "endCursor": "c2"}}}});
764        let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
765        assert_eq!(step, PageStep::Stop);
766        assert!(!unresolved);
767    }
768
769    #[test]
770    fn decide_next_page_detects_cursor_loop() {
771        let body =
772            json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c1"}}}});
773        let (step, _) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
774        assert_eq!(step, PageStep::StopLoop);
775    }
776
777    #[test]
778    fn decide_next_page_defers_to_cursor_when_has_next_unresolved() {
779        // F52: an absent / non-boolean has-next path must NOT silently stop
780        // pagination. With a valid distinct next cursor we keep going, and the
781        // unresolved flag is raised so the caller warns once.
782        let body = json!({"data": {"users": {"pageInfo": {"endCursor": "c2"}}}}); // no hasNextPage
783        let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
784        assert_eq!(
785            step,
786            PageStep::Advance("c2".into()),
787            "unresolved has-next must defer to cursor presence, not stop"
788        );
789        assert!(unresolved, "the caller is told to warn once");
790
791        // Unresolved has-next AND no cursor → genuinely stop (nothing to follow).
792        let body_no_cursor = json!({"data": {"users": {"pageInfo": {}}}});
793        let (step, unresolved) =
794            decide_next_page(&body_no_cursor, &pageinfo_pagination(), Some("c1"));
795        assert_eq!(step, PageStep::Stop);
796        assert!(unresolved);
797    }
798
799    fn offset_pagination(page_size: usize, stop_when_short: bool) -> GraphqlOffsetPagination {
800        GraphqlOffsetPagination {
801            r#type: crate::config::OffsetPaginationKind::Offset,
802            offset_variable: "q_offset".into(),
803            page_size,
804            stop_when_short,
805        }
806    }
807
808    #[test]
809    fn offset_continues_on_full_page() {
810        // A page filled to page_size means there may be more — keep going.
811        assert!(offset_should_continue(250, &offset_pagination(250, true)));
812    }
813
814    #[test]
815    fn offset_stops_on_short_page_when_stop_when_short() {
816        // Fewer than page_size records with stop_when_short: the final page.
817        assert!(!offset_should_continue(100, &offset_pagination(250, true)));
818    }
819
820    #[test]
821    fn offset_continues_on_short_page_when_not_stop_when_short() {
822        // stop_when_short: false keeps paginating on a non-empty short page.
823        assert!(offset_should_continue(100, &offset_pagination(250, false)));
824    }
825
826    #[test]
827    fn offset_always_stops_on_empty_page() {
828        // An empty page terminates regardless of stop_when_short (loop guard).
829        assert!(!offset_should_continue(0, &offset_pagination(250, true)));
830        assert!(!offset_should_continue(0, &offset_pagination(250, false)));
831    }
832
833    #[test]
834    fn offset_exact_page_size_is_full_not_short() {
835        // records_in_page == page_size is a full page (>= not <), so continue.
836        assert!(offset_should_continue(1, &offset_pagination(1, true)));
837    }
838
839    #[test]
840    fn extract_records_with_path() {
841        let config =
842            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { users { id } }")
843                .records_path("$.data.users[*]");
844        let stream = GraphqlStream::new(config);
845        let body = json!({"data": {"users": [{"id": 1}, {"id": 2}]}});
846        let records = stream.extract_records(&body).unwrap();
847        assert_eq!(records.len(), 2);
848        assert_eq!(records[0]["id"], 1);
849    }
850
851    #[test]
852    fn extract_records_without_path_returns_data() {
853        let config =
854            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
855        let stream = GraphqlStream::new(config);
856        let body = json!({"data": {"user": {"id": 1}}});
857        let records = stream.extract_records(&body).unwrap();
858        assert_eq!(records.len(), 1);
859        assert_eq!(records[0]["user"]["id"], 1);
860    }
861
862    #[test]
863    fn extract_records_without_path_null_data_yields_empty() {
864        // A response of `{"data": null}` must NOT emit a bogus null record:
865        // `data` being JSON null means there is nothing to extract, so the
866        // page is empty (#146 LOW).
867        let config =
868            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
869        let stream = GraphqlStream::new(config);
870        let body = json!({ "data": null });
871        let records = stream.extract_records(&body).unwrap();
872        assert!(
873            records.is_empty(),
874            "expected empty Vec for null `data`, got {records:?}"
875        );
876    }
877
878    #[test]
879    fn extract_records_without_path_absent_data_yields_empty() {
880        // No `data` field at all → nothing to extract → empty page (matches
881        // the null-data case rather than forwarding the whole body).
882        let config =
883            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
884        let stream = GraphqlStream::new(config);
885        let body = json!({ "extensions": { "foo": 1 } });
886        let records = stream.extract_records(&body).unwrap();
887        assert!(
888            records.is_empty(),
889            "expected empty Vec when `data` is absent, got {records:?}"
890        );
891    }
892
893    #[test]
894    fn dataset_uri_returns_endpoint() {
895        use faucet_core::Source;
896        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
897            "https://api.example.com/graphql",
898            "query { id }",
899        ));
900        assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
901    }
902
903    #[test]
904    fn dataset_uri_redacts_credentials() {
905        use faucet_core::Source;
906        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
907            "https://user:pw@api.example.com/graphql",
908            "query { id }",
909        ));
910        assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
911    }
912
913    #[test]
914    fn default_retry_policy_reproduces_legacy_constants() {
915        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
916            "https://api.example.com/graphql",
917            "query { id }",
918        ));
919        assert_eq!(stream.retry_policy.max_attempts, RETRY_MAX_ATTEMPTS + 1);
920        assert_eq!(stream.retry_policy.base, RETRY_BASE_BACKOFF);
921    }
922
923    #[test]
924    fn with_retry_policy_overrides_the_default() {
925        let policy = faucet_core::RetryPolicy {
926            max_attempts: 9,
927            base: Duration::from_secs(7),
928            ..faucet_core::RetryPolicy::default()
929        };
930        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
931            "https://api.example.com/graphql",
932            "query { id }",
933        ))
934        .with_retry_policy(policy);
935        assert_eq!(stream.retry_policy.max_attempts, 9);
936        assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
937    }
938
939    #[test]
940    fn cursor_guard_detects_repeats_and_bounds_memory() {
941        let mut g = CursorGuard::new();
942        assert!(!g.is_repeat("a"));
943        assert!(!g.is_repeat("b"));
944        // Any earlier cursor repeating is a cycle, not just the adjacent one.
945        assert!(g.is_repeat("a"));
946        assert!(g.is_repeat("b"));
947
948        // Bounded: after CAP distinct cursors, the oldest is evicted, so the
949        // set never grows without bound on a legitimately large pagination.
950        let mut g = CursorGuard::new();
951        for i in 0..CursorGuard::CAP {
952            assert!(!g.is_repeat(&format!("c{i}")));
953        }
954        assert_eq!(g.order.len(), CursorGuard::CAP);
955        // One more distinct cursor evicts the oldest ("c0").
956        assert!(!g.is_repeat("overflow"));
957        assert_eq!(g.order.len(), CursorGuard::CAP);
958        assert!(!g.seen.contains_key("c0"), "oldest cursor evicted");
959        assert!(g.seen.contains_key("overflow"));
960    }
961}
962
963/// Mutual-TLS unit tests (#495) — lib-level for reliable llvm-cov attribution.
964#[cfg(all(test, feature = "mtls"))]
965mod mtls_tests {
966    use super::*;
967    use faucet_core::TlsClientConfig;
968
969    const CERT: &str = include_str!("../tests/fixtures/mtls/cert.pem");
970    const KEY: &str = include_str!("../tests/fixtures/mtls/key.pem");
971
972    fn pem() -> TlsClientConfig {
973        TlsClientConfig {
974            client_cert: Some(CERT.to_string()),
975            client_key: Some(KEY.to_string()),
976            ..Default::default()
977        }
978    }
979
980    fn cfg(tls: TlsClientConfig) -> GraphqlStreamConfig {
981        GraphqlStreamConfig::new("https://x.test/graphql", "{ ping }").tls(tls)
982    }
983
984    #[test]
985    fn pem_identity_builds() {
986        assert!(GraphqlStream::try_new(cfg(pem())).is_ok());
987    }
988
989    #[test]
990    fn min_version_branches_are_exercised() {
991        let mut tls = pem();
992        tls.min_version = Some("1.2".into());
993        assert!(GraphqlStream::try_new(cfg(tls)).is_ok());
994        // 1.3 exercises the other branch; some native-tls backends reject a 1.3
995        // floor at build time, so only require it not to panic.
996        let mut tls = pem();
997        tls.min_version = Some("1.3".into());
998        let _ = GraphqlStream::try_new(cfg(tls));
999    }
1000
1001    #[test]
1002    fn pkcs12_identity_builds() {
1003        let p12 = concat!(
1004            env!("CARGO_MANIFEST_DIR"),
1005            "/tests/fixtures/mtls/identity.p12"
1006        );
1007        let tls = TlsClientConfig {
1008            client_identity_pkcs12: Some(p12.to_string()),
1009            pkcs12_password: Some("changeit".into()),
1010            ..Default::default()
1011        };
1012        assert!(GraphqlStream::try_new(cfg(tls)).is_ok());
1013    }
1014
1015    #[test]
1016    fn invalid_pem_errors_without_leaking_key() {
1017        let tls = TlsClientConfig {
1018            client_cert: Some("-----BEGIN CERTIFICATE-----\nbad\n-----END CERTIFICATE-----".into()),
1019            client_key: Some("SUPERSECRETKEY".into()),
1020            ..Default::default()
1021        };
1022        let err = GraphqlStream::try_new(cfg(tls))
1023            .map(|_| ())
1024            .expect_err("bad PEM must error");
1025        assert!(!err.to_string().contains("SUPERSECRETKEY"));
1026    }
1027
1028    #[test]
1029    fn invalid_tls_shape_errors() {
1030        let mut tls = pem();
1031        tls.client_identity_pkcs12 = Some("/x.p12".into());
1032        assert!(GraphqlStream::try_new(cfg(tls)).is_err());
1033    }
1034
1035    #[test]
1036    fn missing_pkcs12_file_errors() {
1037        let tls = TlsClientConfig {
1038            client_identity_pkcs12: Some("/no/such.p12".into()),
1039            pkcs12_password: Some("x".into()),
1040            ..Default::default()
1041        };
1042        assert!(GraphqlStream::try_new(cfg(tls)).is_err());
1043    }
1044
1045    #[test]
1046    fn config_validate_checks_tls() {
1047        assert!(cfg(pem()).validate().is_ok());
1048        let mut bad = pem();
1049        bad.client_identity_pkcs12 = Some("/x.p12".into());
1050        assert!(cfg(bad).validate().is_err());
1051    }
1052}