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        // The query string; for offset `substitute_in_query` mode it is
284        // rewritten per request with the current offset (ShopifyQL etc.).
285        let mut query = self.config.query.clone();
286
287        // Inject the per-request pagination variable(s).
288        match &self.config.pagination {
289            // Cursor mode: inject the `after` cursor (once we have one) and the
290            // page-size variable from `batch_size`. `batch_size = 0` is the
291            // "use upstream default" sentinel — we omit the size variable.
292            Some(GraphqlPaginationSpec::Cursor(pag)) => {
293                if let (Some(cursor_val), Value::Object(map)) = (cursor, &mut variables) {
294                    map.insert(pag.cursor_variable.clone(), json!(cursor_val));
295                }
296                if self.config.batch_size != 0
297                    && let Value::Object(map) = &mut variables
298                {
299                    map.insert(
300                        pag.page_size_variable.clone(),
301                        json!(self.config.batch_size),
302                    );
303                }
304            }
305            // Offset mode: either substitute `${offset_variable}` into the query
306            // string (ShopifyQL's string-literal `LIMIT … OFFSET …`, #569) or
307            // inject the current offset as a JSON GraphQL variable (#550). The
308            // page size is not injected — the user bakes the limit into the query.
309            Some(GraphqlPaginationSpec::Offset(off)) => {
310                if off.substitute_in_query {
311                    let token = format!("${{{}}}", off.offset_variable);
312                    query = query.replace(&token, &offset.to_string());
313                } else if let Value::Object(map) = &mut variables {
314                    map.insert(off.offset_variable.clone(), json!(offset));
315                }
316            }
317            None => {}
318        }
319
320        let payload = json!({
321            "query": query,
322            "variables": variables,
323        });
324
325        let mut req = self
326            .client
327            .post(&self.config.endpoint)
328            .headers(self.config.headers.clone())
329            .json(&payload);
330
331        // Resolve credentials to concrete auth. A shared auth provider (from
332        // `auth: { ref }` or injected by a library caller) takes precedence;
333        // otherwise the inline auth config is used directly.
334        let effective_auth: GraphqlAuth = if let Some(provider) = &self.auth_provider {
335            credential_to_auth(provider.credential().await?)
336        } else {
337            match &self.config.auth {
338                AuthSpec::Inline(a) => a.clone(),
339                AuthSpec::Reference(r) => {
340                    return Err(FaucetError::Auth(format!(
341                        "auth references provider '{}' but no provider was supplied; \
342                         set one via the CLI `auth:` catalog or `with_auth_provider`",
343                        r.name
344                    )));
345                }
346            }
347        };
348
349        // Apply resolved auth to the request.
350        match effective_auth {
351            GraphqlAuth::None => {}
352            GraphqlAuth::Bearer { token } => {
353                req = req.bearer_auth(token);
354            }
355            GraphqlAuth::Custom { headers } => {
356                let mut hm = reqwest::header::HeaderMap::new();
357                for (name, value) in &headers {
358                    let n =
359                        reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
360                            FaucetError::Auth(format!("invalid custom header name {name:?}: {e}"))
361                        })?;
362                    let v = reqwest::header::HeaderValue::from_str(value).map_err(|e| {
363                        FaucetError::Auth(format!("invalid custom header value for {name:?}: {e}"))
364                    })?;
365                    hm.insert(n, v);
366                }
367                req = req.headers(hm);
368            }
369        }
370
371        // Retry transient failures (5xx / connection resets) with jittered
372        // backoff, matching the REST source's reliability layer (#78/#16).
373        // GraphQL-level `errors` in a 200 body are application errors and are
374        // handled below — they are not retried here.
375        let body: Value = faucet_core::execute_with_policy(&self.retry_policy, None, || {
376            let attempt = req.try_clone();
377            async move {
378                let req = attempt.ok_or_else(|| {
379                    FaucetError::Source("graphql: request is not cloneable for retry".into())
380                })?;
381                let resp = req.send().await.map_err(FaucetError::Http)?;
382                let resp = util::check_http_response(resp, DEFAULT_ERROR_BODY_MAX_LEN).await?;
383                resp.json().await.map_err(FaucetError::Http)
384            }
385        })
386        .await?;
387
388        // Check for GraphQL-level errors.
389        if let Some(errors) = body.get("errors")
390            && let Some(arr) = errors.as_array()
391            && !arr.is_empty()
392        {
393            let msg = arr
394                .iter()
395                .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
396                .collect::<Vec<_>>()
397                .join("; ");
398            // Surface "first: must be non-null" / similar variable validation
399            // errors as `FaucetError::Config` so callers can react to the
400            // `batch_size = 0` sentinel hitting a schema that requires a
401            // non-null page-size argument. Detect by message substring —
402            // GraphQL servers don't standardise an error-code field.
403            let lower = msg.to_lowercase();
404            if self.config.batch_size == 0
405                && let Some(GraphqlPaginationSpec::Cursor(pag)) = &self.config.pagination
406            {
407                let var_name = pag.page_size_variable.to_lowercase();
408                if lower.contains(&var_name)
409                    && (lower.contains("non-null")
410                        || lower.contains("non null")
411                        || lower.contains("must not be null")
412                        || lower.contains("cannot be null")
413                        || lower.contains("required"))
414                {
415                    return Err(FaucetError::Config(format!(
416                        "batch_size = 0 requires the upstream to accept a null {}: argument \
417                         (GraphQL errors: {msg})",
418                        pag.page_size_variable
419                    )));
420                }
421            }
422            return Err(FaucetError::HttpStatus {
423                status: 200,
424                url: self.config.endpoint.clone(),
425                body: format!("GraphQL errors: {msg}"),
426            });
427        }
428
429        Ok(body)
430    }
431
432    /// Extract records from a GraphQL response using the configured JSONPath.
433    fn extract_records(&self, body: &Value) -> Result<Vec<Value>, FaucetError> {
434        match &self.config.records_path {
435            Some(path) => util::extract_records(body, Some(path)),
436            None => {
437                // GraphQL-specific: return the `data` field as a single
438                // record. A `data` that is JSON null (or absent entirely)
439                // means there is nothing to extract — emit an empty page
440                // rather than forwarding a bogus null record to the sink
441                // (#146 LOW).
442                match body.get("data") {
443                    Some(Value::Null) | None => Ok(Vec::new()),
444                    Some(data) => Ok(vec![data.clone()]),
445                }
446            }
447        }
448    }
449
450    /// Core pagination loop yielded as a [`StreamPage`] stream.
451    ///
452    /// Each upstream GraphQL response → one [`StreamPage`]. The page size
453    /// variable in the request comes from [`GraphqlStreamConfig::batch_size`];
454    /// `batch_size = 0` omits it so the upstream uses its own default page
455    /// size and emits a single page.
456    ///
457    /// Bookmarks are always `None` — the GraphQL source has no
458    /// incremental-replication mode today. The
459    /// [`bookmark_emitted`-style trailing-checkpoint](https://github.com/faucet-hq/faucet-stream/commit/e6fdca5)
460    /// guard from the REST source is preserved structurally so any future
461    /// incremental mode picks it up without re-deriving the pattern.
462    fn stream_pages_inner(
463        &self,
464        context: &std::collections::HashMap<String, Value>,
465    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + '_>> {
466        // Own the context so it can live inside the async-stream generator.
467        let owned_context: std::collections::HashMap<String, Value> = context.clone();
468
469        Box::pin(async_stream::try_stream! {
470            let mut cursor: Option<String> = None;
471            let mut offset = 0usize;
472            let mut cursor_guard = CursorGuard::new();
473            let mut pages_fetched = 0usize;
474            let mut warned_unresolved_has_next = false;
475            // No incremental replication today — `running_max` stays `None`.
476            // The structure mirrors the REST source so a future replication
477            // mode can plug into the same scaffolding without reworking the
478            // bookmark guard.
479            let running_max: Option<Value> = None;
480            let mut bookmark_emitted = false;
481
482            loop {
483                if let Some(max) = self.config.max_pages
484                    && pages_fetched >= max
485                {
486                    tracing::warn!("max pages ({max}) reached");
487                    break;
488                }
489
490                let body = self.execute_query(&cursor, offset, &owned_context).await?;
491                let records = self.extract_records(&body)?;
492                let records_in_page = records.len();
493                pages_fetched += 1;
494
495                // Advance pagination state BEFORE yielding the current page,
496                // so the bookmark is only attached on the final page.
497                let has_next = match &self.config.pagination {
498                    Some(GraphqlPaginationSpec::Cursor(pag)) => {
499                        let (step, unresolved) =
500                            decide_next_page(&body, pag, cursor.as_deref());
501                        if unresolved && !warned_unresolved_has_next {
502                            tracing::warn!(
503                                path = %pag.has_next_page_path,
504                                "GraphQL has_next_page path did not resolve to a boolean; \
505                                 deferring to cursor presence to decide pagination"
506                            );
507                            warned_unresolved_has_next = true;
508                        }
509                        match step {
510                            PageStep::Stop => false,
511                            PageStep::StopLoop => {
512                                tracing::warn!("cursor loop detected, stopping pagination");
513                                false
514                            }
515                            PageStep::Advance(next) => {
516                                if cursor_guard.is_repeat(&next) {
517                                    tracing::warn!(
518                                        "cursor cycle detected (cursor already seen), stopping pagination"
519                                    );
520                                    false
521                                } else {
522                                    cursor = Some(next);
523                                    true
524                                }
525                            }
526                        }
527                    }
528                    Some(GraphqlPaginationSpec::Offset(off)) => {
529                        let advance = offset_should_continue(records_in_page, off);
530                        if advance {
531                            offset += off.page_size;
532                        }
533                        advance
534                    }
535                    None => false,
536                };
537
538                if has_next {
539                    // Intermediate page — bookmark stays `None`.
540                    yield StreamPage { records, bookmark: None };
541                } else {
542                    // Final page — attach the consolidated bookmark (always
543                    // `None` until incremental mode lands).
544                    bookmark_emitted = running_max.is_some();
545                    yield StreamPage {
546                        records,
547                        bookmark: running_max.clone(),
548                    };
549                    break;
550                }
551            }
552
553            // Trailing checkpoint: if the loop exited (e.g. via `max_pages`
554            // truncation) without carrying the bookmark on a real page, emit
555            // one empty page carrying it so the pipeline persists progress.
556            // No-op today because `running_max` is always `None`, but kept so
557            // a future incremental mode inherits the guard from the REST
558            // source's regression fix (commit e6fdca5).
559            if !bookmark_emitted && running_max.is_some() {
560                yield StreamPage {
561                    records: Vec::new(),
562                    bookmark: running_max,
563                };
564            }
565
566            tracing::info!(
567                pages = pages_fetched,
568                batch_size = self.config.batch_size,
569                "GraphQL source stream complete",
570            );
571        })
572    }
573}
574
575#[async_trait]
576impl faucet_core::Source for GraphqlStream {
577    async fn fetch_with_context(
578        &self,
579        context: &std::collections::HashMap<String, serde_json::Value>,
580    ) -> Result<Vec<Value>, FaucetError> {
581        self.fetch_all_with_context(context).await
582    }
583
584    /// Stream GraphQL responses page-by-page without buffering the full
585    /// result set. The trait-level `batch_size` argument is ignored in
586    /// favour of [`GraphqlStreamConfig::batch_size`] — the config field is
587    /// the user-facing knob the README documents, and routing the
588    /// pipeline-supplied hint through it would silently override an
589    /// explicit config value.
590    fn stream_pages<'a>(
591        &'a self,
592        context: &'a std::collections::HashMap<String, Value>,
593        _batch_size: usize,
594    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
595        self.stream_pages_inner(context)
596    }
597
598    fn connector_name(&self) -> &'static str {
599        "graphql"
600    }
601
602    fn config_schema(&self) -> serde_json::Value {
603        serde_json::to_value(faucet_core::schema_for!(GraphqlStreamConfig))
604            .expect("schema serialization")
605    }
606
607    fn dataset_uri(&self) -> String {
608        faucet_core::redact_uri_credentials(&self.config.endpoint)
609    }
610}
611
612fn extract_string(body: &Value, path: &str) -> Option<String> {
613    let results = body.query(path).ok()?;
614    match results.first()? {
615        Value::String(s) => Some(s.clone()),
616        _ => None,
617    }
618}
619
620fn extract_bool(body: &Value, path: &str) -> Option<bool> {
621    let results = body.query(path).ok()?;
622    results.first()?.as_bool()
623}
624
625/// What to do after fetching a page.
626#[derive(Debug, PartialEq)]
627enum PageStep {
628    /// No further pages (has-next is `false`, or there is no next cursor).
629    Stop,
630    /// The server returned the cursor we just used — advancing would re-fetch
631    /// the same page. Caller warns and stops.
632    StopLoop,
633    /// Fetch another page with this cursor.
634    Advance(String),
635}
636
637/// Pure pagination-advance decision shared by the eager and streaming paths.
638///
639/// `prev_cursor` is the cursor just used (for loop detection). The returned
640/// bool is `true` when the configured `has_next_page_path` did **not** resolve
641/// to a boolean: that is treated as "can't tell" and we **defer to cursor
642/// presence** rather than silently stopping — an unmatched has-next path must
643/// not drop the remaining pages of a paginated result (F52). The caller warns
644/// once on that condition.
645fn decide_next_page(
646    body: &Value,
647    pag: &GraphqlPagination,
648    prev_cursor: Option<&str>,
649) -> (PageStep, bool) {
650    let (stop, unresolved) = match extract_bool(body, &pag.has_next_page_path) {
651        Some(false) => (true, false),
652        Some(true) => (false, false),
653        // Path absent / not a boolean: defer the decision to the cursor signal.
654        None => (false, true),
655    };
656    if stop {
657        return (PageStep::Stop, unresolved);
658    }
659    match extract_string(body, &pag.cursor_path) {
660        None => (PageStep::Stop, unresolved),
661        Some(next) if Some(next.as_str()) == prev_cursor => (PageStep::StopLoop, unresolved),
662        Some(next) => (PageStep::Advance(next), unresolved),
663    }
664}
665
666/// Pure offset-pagination advance decision.
667///
668/// Returns `true` when another page should be fetched (the caller then advances
669/// the offset by `page_size`), `false` to stop. Termination rules:
670///
671/// - A **fully empty** page (0 records) always stops — this is the unconditional
672///   loop guard that keeps `stop_when_short: false` from paginating forever.
673/// - With `stop_when_short` (the default), a **short** page — fewer than
674///   `page_size` records — is the last one and stops pagination.
675/// - Otherwise (a full page, or `stop_when_short: false` with a non-empty page)
676///   pagination continues.
677fn offset_should_continue(records_in_page: usize, off: &GraphqlOffsetPagination) -> bool {
678    if records_in_page == 0 {
679        return false;
680    }
681    if off.stop_when_short && records_in_page < off.page_size {
682        return false;
683    }
684    true
685}
686
687/// Bounded record of recently-advanced pagination cursors.
688///
689/// [`decide_next_page`] only compares against the *immediately previous* cursor,
690/// so a server that returns `hasNextPage: true` while cycling its cursor across
691/// two or more values (`c1→c2→c1→c2…`) would never trip that guard and — with
692/// `max_pages` unset — paginate forever, re-emitting the same pages (#466 M2).
693/// This catches any such cycle by remembering the cursors already seen.
694///
695/// Bounded to [`Self::CAP`] entries so the streaming path keeps its O(page)
696/// memory guarantee on a legitimately large result set (whose cursors are all
697/// distinct, so eviction never causes a false positive). A cycle length beyond
698/// the cap is not realistic for a real endpoint.
699struct CursorGuard {
700    seen: HashMap<String, ()>,
701    order: std::collections::VecDeque<String>,
702}
703
704impl CursorGuard {
705    const CAP: usize = 4096;
706
707    fn new() -> Self {
708        Self {
709            seen: HashMap::new(),
710            order: std::collections::VecDeque::new(),
711        }
712    }
713
714    /// Record `cursor`; return `true` if it had already been seen (a cycle).
715    fn is_repeat(&mut self, cursor: &str) -> bool {
716        if self.seen.contains_key(cursor) {
717            return true;
718        }
719        if self.order.len() >= Self::CAP
720            && let Some(old) = self.order.pop_front()
721        {
722            self.seen.remove(&old);
723        }
724        self.seen.insert(cursor.to_string(), ());
725        self.order.push_back(cursor.to_string());
726        false
727    }
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733
734    #[test]
735    fn extract_string_from_json() {
736        let body = json!({"data": {"users": {"pageInfo": {"endCursor": "abc123"}}}});
737        assert_eq!(
738            extract_string(&body, "$.data.users.pageInfo.endCursor"),
739            Some("abc123".into())
740        );
741    }
742
743    #[test]
744    fn extract_bool_from_json() {
745        let body = json!({"data": {"users": {"pageInfo": {"hasNextPage": true}}}});
746        assert_eq!(
747            extract_bool(&body, "$.data.users.pageInfo.hasNextPage"),
748            Some(true)
749        );
750    }
751
752    fn pageinfo_pagination() -> GraphqlPagination {
753        GraphqlPagination {
754            has_next_page_path: "$.data.users.pageInfo.hasNextPage".into(),
755            cursor_path: "$.data.users.pageInfo.endCursor".into(),
756            ..GraphqlPagination::default()
757        }
758    }
759
760    #[test]
761    fn decide_next_page_advances_when_has_next_true() {
762        let body =
763            json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c2"}}}});
764        let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
765        assert_eq!(step, PageStep::Advance("c2".into()));
766        assert!(!unresolved);
767    }
768
769    #[test]
770    fn decide_next_page_stops_when_has_next_false() {
771        let body =
772            json!({"data": {"users": {"pageInfo": {"hasNextPage": false, "endCursor": "c2"}}}});
773        let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
774        assert_eq!(step, PageStep::Stop);
775        assert!(!unresolved);
776    }
777
778    #[test]
779    fn decide_next_page_detects_cursor_loop() {
780        let body =
781            json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c1"}}}});
782        let (step, _) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
783        assert_eq!(step, PageStep::StopLoop);
784    }
785
786    #[test]
787    fn decide_next_page_defers_to_cursor_when_has_next_unresolved() {
788        // F52: an absent / non-boolean has-next path must NOT silently stop
789        // pagination. With a valid distinct next cursor we keep going, and the
790        // unresolved flag is raised so the caller warns once.
791        let body = json!({"data": {"users": {"pageInfo": {"endCursor": "c2"}}}}); // no hasNextPage
792        let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
793        assert_eq!(
794            step,
795            PageStep::Advance("c2".into()),
796            "unresolved has-next must defer to cursor presence, not stop"
797        );
798        assert!(unresolved, "the caller is told to warn once");
799
800        // Unresolved has-next AND no cursor → genuinely stop (nothing to follow).
801        let body_no_cursor = json!({"data": {"users": {"pageInfo": {}}}});
802        let (step, unresolved) =
803            decide_next_page(&body_no_cursor, &pageinfo_pagination(), Some("c1"));
804        assert_eq!(step, PageStep::Stop);
805        assert!(unresolved);
806    }
807
808    fn offset_pagination(page_size: usize, stop_when_short: bool) -> GraphqlOffsetPagination {
809        GraphqlOffsetPagination {
810            r#type: crate::config::OffsetPaginationKind::Offset,
811            offset_variable: "q_offset".into(),
812            page_size,
813            stop_when_short,
814            substitute_in_query: false,
815        }
816    }
817
818    #[test]
819    fn offset_continues_on_full_page() {
820        // A page filled to page_size means there may be more — keep going.
821        assert!(offset_should_continue(250, &offset_pagination(250, true)));
822    }
823
824    #[test]
825    fn offset_stops_on_short_page_when_stop_when_short() {
826        // Fewer than page_size records with stop_when_short: the final page.
827        assert!(!offset_should_continue(100, &offset_pagination(250, true)));
828    }
829
830    #[test]
831    fn offset_continues_on_short_page_when_not_stop_when_short() {
832        // stop_when_short: false keeps paginating on a non-empty short page.
833        assert!(offset_should_continue(100, &offset_pagination(250, false)));
834    }
835
836    #[test]
837    fn offset_always_stops_on_empty_page() {
838        // An empty page terminates regardless of stop_when_short (loop guard).
839        assert!(!offset_should_continue(0, &offset_pagination(250, true)));
840        assert!(!offset_should_continue(0, &offset_pagination(250, false)));
841    }
842
843    #[test]
844    fn offset_exact_page_size_is_full_not_short() {
845        // records_in_page == page_size is a full page (>= not <), so continue.
846        assert!(offset_should_continue(1, &offset_pagination(1, true)));
847    }
848
849    #[test]
850    fn extract_records_with_path() {
851        let config =
852            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { users { id } }")
853                .records_path("$.data.users[*]");
854        let stream = GraphqlStream::new(config);
855        let body = json!({"data": {"users": [{"id": 1}, {"id": 2}]}});
856        let records = stream.extract_records(&body).unwrap();
857        assert_eq!(records.len(), 2);
858        assert_eq!(records[0]["id"], 1);
859    }
860
861    #[test]
862    fn extract_records_without_path_returns_data() {
863        let config =
864            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
865        let stream = GraphqlStream::new(config);
866        let body = json!({"data": {"user": {"id": 1}}});
867        let records = stream.extract_records(&body).unwrap();
868        assert_eq!(records.len(), 1);
869        assert_eq!(records[0]["user"]["id"], 1);
870    }
871
872    #[test]
873    fn extract_records_without_path_null_data_yields_empty() {
874        // A response of `{"data": null}` must NOT emit a bogus null record:
875        // `data` being JSON null means there is nothing to extract, so the
876        // page is empty (#146 LOW).
877        let config =
878            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
879        let stream = GraphqlStream::new(config);
880        let body = json!({ "data": null });
881        let records = stream.extract_records(&body).unwrap();
882        assert!(
883            records.is_empty(),
884            "expected empty Vec for null `data`, got {records:?}"
885        );
886    }
887
888    #[test]
889    fn extract_records_without_path_absent_data_yields_empty() {
890        // No `data` field at all → nothing to extract → empty page (matches
891        // the null-data case rather than forwarding the whole body).
892        let config =
893            GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
894        let stream = GraphqlStream::new(config);
895        let body = json!({ "extensions": { "foo": 1 } });
896        let records = stream.extract_records(&body).unwrap();
897        assert!(
898            records.is_empty(),
899            "expected empty Vec when `data` is absent, got {records:?}"
900        );
901    }
902
903    #[test]
904    fn dataset_uri_returns_endpoint() {
905        use faucet_core::Source;
906        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
907            "https://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 dataset_uri_redacts_credentials() {
915        use faucet_core::Source;
916        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
917            "https://user:pw@api.example.com/graphql",
918            "query { id }",
919        ));
920        assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
921    }
922
923    #[test]
924    fn default_retry_policy_reproduces_legacy_constants() {
925        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
926            "https://api.example.com/graphql",
927            "query { id }",
928        ));
929        assert_eq!(stream.retry_policy.max_attempts, RETRY_MAX_ATTEMPTS + 1);
930        assert_eq!(stream.retry_policy.base, RETRY_BASE_BACKOFF);
931    }
932
933    #[test]
934    fn with_retry_policy_overrides_the_default() {
935        let policy = faucet_core::RetryPolicy {
936            max_attempts: 9,
937            base: Duration::from_secs(7),
938            ..faucet_core::RetryPolicy::default()
939        };
940        let stream = GraphqlStream::new(GraphqlStreamConfig::new(
941            "https://api.example.com/graphql",
942            "query { id }",
943        ))
944        .with_retry_policy(policy);
945        assert_eq!(stream.retry_policy.max_attempts, 9);
946        assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
947    }
948
949    #[test]
950    fn cursor_guard_detects_repeats_and_bounds_memory() {
951        let mut g = CursorGuard::new();
952        assert!(!g.is_repeat("a"));
953        assert!(!g.is_repeat("b"));
954        // Any earlier cursor repeating is a cycle, not just the adjacent one.
955        assert!(g.is_repeat("a"));
956        assert!(g.is_repeat("b"));
957
958        // Bounded: after CAP distinct cursors, the oldest is evicted, so the
959        // set never grows without bound on a legitimately large pagination.
960        let mut g = CursorGuard::new();
961        for i in 0..CursorGuard::CAP {
962            assert!(!g.is_repeat(&format!("c{i}")));
963        }
964        assert_eq!(g.order.len(), CursorGuard::CAP);
965        // One more distinct cursor evicts the oldest ("c0").
966        assert!(!g.is_repeat("overflow"));
967        assert_eq!(g.order.len(), CursorGuard::CAP);
968        assert!(!g.seen.contains_key("c0"), "oldest cursor evicted");
969        assert!(g.seen.contains_key("overflow"));
970    }
971}
972
973/// Mutual-TLS unit tests (#495) — lib-level for reliable llvm-cov attribution.
974#[cfg(all(test, feature = "mtls"))]
975mod mtls_tests {
976    use super::*;
977    use faucet_core::TlsClientConfig;
978
979    const CERT: &str = include_str!("../tests/fixtures/mtls/cert.pem");
980    const KEY: &str = include_str!("../tests/fixtures/mtls/key.pem");
981
982    fn pem() -> TlsClientConfig {
983        TlsClientConfig {
984            client_cert: Some(CERT.to_string()),
985            client_key: Some(KEY.to_string()),
986            ..Default::default()
987        }
988    }
989
990    fn cfg(tls: TlsClientConfig) -> GraphqlStreamConfig {
991        GraphqlStreamConfig::new("https://x.test/graphql", "{ ping }").tls(tls)
992    }
993
994    #[test]
995    fn pem_identity_builds() {
996        assert!(GraphqlStream::try_new(cfg(pem())).is_ok());
997    }
998
999    #[test]
1000    fn min_version_branches_are_exercised() {
1001        let mut tls = pem();
1002        tls.min_version = Some("1.2".into());
1003        assert!(GraphqlStream::try_new(cfg(tls)).is_ok());
1004        // 1.3 exercises the other branch; some native-tls backends reject a 1.3
1005        // floor at build time, so only require it not to panic.
1006        let mut tls = pem();
1007        tls.min_version = Some("1.3".into());
1008        let _ = GraphqlStream::try_new(cfg(tls));
1009    }
1010
1011    #[test]
1012    fn pkcs12_identity_builds() {
1013        let p12 = concat!(
1014            env!("CARGO_MANIFEST_DIR"),
1015            "/tests/fixtures/mtls/identity.p12"
1016        );
1017        let tls = TlsClientConfig {
1018            client_identity_pkcs12: Some(p12.to_string()),
1019            pkcs12_password: Some("changeit".into()),
1020            ..Default::default()
1021        };
1022        assert!(GraphqlStream::try_new(cfg(tls)).is_ok());
1023    }
1024
1025    #[test]
1026    fn invalid_pem_errors_without_leaking_key() {
1027        let tls = TlsClientConfig {
1028            client_cert: Some("-----BEGIN CERTIFICATE-----\nbad\n-----END CERTIFICATE-----".into()),
1029            client_key: Some("SUPERSECRETKEY".into()),
1030            ..Default::default()
1031        };
1032        let err = GraphqlStream::try_new(cfg(tls))
1033            .map(|_| ())
1034            .expect_err("bad PEM must error");
1035        assert!(!err.to_string().contains("SUPERSECRETKEY"));
1036    }
1037
1038    #[test]
1039    fn invalid_tls_shape_errors() {
1040        let mut tls = pem();
1041        tls.client_identity_pkcs12 = Some("/x.p12".into());
1042        assert!(GraphqlStream::try_new(cfg(tls)).is_err());
1043    }
1044
1045    #[test]
1046    fn missing_pkcs12_file_errors() {
1047        let tls = TlsClientConfig {
1048            client_identity_pkcs12: Some("/no/such.p12".into()),
1049            pkcs12_password: Some("x".into()),
1050            ..Default::default()
1051        };
1052        assert!(GraphqlStream::try_new(cfg(tls)).is_err());
1053    }
1054
1055    #[test]
1056    fn config_validate_checks_tls() {
1057        assert!(cfg(pem()).validate().is_ok());
1058        let mut bad = pem();
1059        bad.client_identity_pkcs12 = Some("/x.p12".into());
1060        assert!(cfg(bad).validate().is_err());
1061    }
1062}