Skip to main content

faucet_source_rest/
stream.rs

1//! The main REST stream executor.
2
3use crate::auth::Auth;
4use crate::auth::oauth2::TokenCache;
5use crate::auth::token_endpoint::TokenEndpointCache;
6use crate::config::{RestStreamConfig, TlsClientConfig};
7use crate::extract;
8use crate::pagination::{PaginationState, PaginationStyle};
9use crate::retry;
10use async_trait::async_trait;
11use faucet_core::replication::{
12    BindTarget, ReplicationMethod, filter_incremental, max_replication_value, max_value,
13};
14use faucet_core::schema;
15use faucet_core::{AuthSpec, Credential, CredentialPlacement, FaucetError, SharedAuthProvider};
16use futures_core::Stream;
17use reqwest::Client;
18use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
19use serde::Deserialize;
20use serde_json::Value;
21use std::collections::HashMap;
22use std::pin::Pin;
23use std::sync::Arc;
24use std::time::Duration;
25use tokio::sync::Mutex as AsyncMutex;
26
27/// A configured REST API stream that handles pagination, auth, and extraction.
28pub struct RestStream {
29    config: RestStreamConfig,
30    client: Client,
31    /// Shared OAuth2 token cache (only used when `config.auth` is `Auth::OAuth2`).
32    token_cache: TokenCache,
33    /// Shared token endpoint cache (only used when `config.auth` is `Auth::TokenEndpoint`).
34    token_endpoint_cache: TokenEndpointCache,
35    /// Optional shared auth provider. Set when `config.auth` is an
36    /// `AuthSpec::Reference` resolved by the caller (e.g. the CLI `auth:`
37    /// catalog), or injected directly by a library caller to share one token
38    /// across multiple sources. When present it takes precedence over inline
39    /// auth.
40    auth_provider: Option<SharedAuthProvider>,
41    /// Bookmark applied at runtime via
42    /// [`Source::apply_start_bookmark`](faucet_core::Source::apply_start_bookmark).
43    /// Takes precedence over `config.start_replication_value` when set.
44    runtime_start: Arc<AsyncMutex<Option<Value>>>,
45    /// Rendered lower/upper bounds for the current datetime window (#527),
46    /// applied to each request by [`execute_request_once`](Self::execute_request_once)
47    /// alongside any [`replication_bind`](RestStreamConfig::replication_bind). Set
48    /// by the window loop in `stream_pages_inner` before each window's pages;
49    /// empty when no `window:` block is configured. Each entry is
50    /// `(target, name, rendered-value)`.
51    window_binds: Arc<AsyncMutex<Vec<(BindTarget, String, String)>>>,
52    /// Test-only override for the "now" upper bound of datetime window slicing
53    /// (#527). `None` in production (uses `Utc::now()`); set by unit tests so the
54    /// window enumeration is deterministic.
55    now_override: Option<chrono::DateTime<chrono::Utc>>,
56    /// Retry policy for transient request failures. Built in `new()` from the
57    /// REST source's own `config.max_retries` / `config.retry_backoff`. Fed into
58    /// the REST `retry::execute_with_retry` runner (which keeps its 429 /
59    /// `Retry-After` handling). Overridable via
60    /// [`with_retry_policy`](Self::with_retry_policy) — but the REST connector's
61    /// own legacy `max_retries` / `retry_backoff` fields take precedence when the
62    /// user has set them away from their defaults.
63    retry_policy: faucet_core::RetryPolicy,
64    /// Static request headers (from `config.headers`, #539), validated once in
65    /// [`new`](Self::new) into a [`HeaderMap`] and merged into **every** request
66    /// (data pages, async-job requests, `$metadata` probes) *before* the auth
67    /// provider's placements — so an auth header of the same name wins.
68    static_headers: HeaderMap,
69}
70
71/// Default value of [`RestStreamConfig::max_retries`]. When the user leaves this
72/// untouched, an injected [`RetryPolicy`](faucet_core::RetryPolicy) is allowed to
73/// override it (see [`RestStream::with_retry_policy`]).
74const DEFAULT_MAX_RETRIES: u32 = 3;
75/// Default value of [`RestStreamConfig::retry_backoff`]. Same precedence rule as
76/// [`DEFAULT_MAX_RETRIES`].
77const DEFAULT_RETRY_BACKOFF: Duration = Duration::from_secs(1);
78
79/// Attach a mutual-TLS client identity (from [`TlsClientConfig`]) to the HTTP
80/// client builder. Only compiled with the `mtls` feature; the non-`mtls` stub
81/// errors so a `tls:` block on a build without the feature fails loudly rather
82/// than silently sending no client certificate.
83#[cfg(feature = "mtls")]
84fn apply_client_tls(
85    builder: reqwest::ClientBuilder,
86    tls: &TlsClientConfig,
87) -> Result<reqwest::ClientBuilder, FaucetError> {
88    let identity = build_identity(tls)?;
89    // Use the native-tls backend explicitly: the identity is built with
90    // native-tls constructors, and the workspace may also have rustls compiled
91    // in (feature unification) which would otherwise be selected.
92    let mut builder = builder.identity(identity).use_native_tls();
93    if let Some(v) = &tls.min_version {
94        // `TlsClientConfig::validate` guarantees `v` is "1.2" or "1.3".
95        let version = if v == "1.3" {
96            reqwest::tls::Version::TLS_1_3
97        } else {
98            reqwest::tls::Version::TLS_1_2
99        };
100        builder = builder.min_tls_version(version);
101    }
102    Ok(builder)
103}
104
105#[cfg(not(feature = "mtls"))]
106fn apply_client_tls(
107    _builder: reqwest::ClientBuilder,
108    _tls: &TlsClientConfig,
109) -> Result<reqwest::ClientBuilder, FaucetError> {
110    Err(FaucetError::Config(
111        "a `tls:` (mutual-TLS) block is configured, but this build of \
112         faucet-source-rest lacks the `mtls` feature; rebuild with \
113         `--features mtls`"
114            .into(),
115    ))
116}
117
118/// Build a [`reqwest::Identity`] from the PEM pair or the PKCS#12 file. Errors
119/// never echo key material — only the backend's opaque parse message.
120#[cfg(feature = "mtls")]
121fn build_identity(tls: &TlsClientConfig) -> Result<reqwest::Identity, FaucetError> {
122    if let Some(p12_path) = &tls.client_identity_pkcs12 {
123        let der = std::fs::read(p12_path).map_err(|e| {
124            FaucetError::Config(format!(
125                "tls: could not read PKCS#12 file {p12_path:?}: {e}"
126            ))
127        })?;
128        let password = tls.pkcs12_password.as_deref().unwrap_or("");
129        reqwest::Identity::from_pkcs12_der(&der, password)
130            .map_err(|e| FaucetError::Config(format!("tls: invalid PKCS#12 identity: {e}")))
131    } else {
132        // `validate()` guarantees both are present on the PEM path.
133        let cert = tls.client_cert.as_deref().unwrap_or_default();
134        let key = tls.client_key.as_deref().unwrap_or_default();
135        reqwest::Identity::from_pkcs8_pem(cert.as_bytes(), key.as_bytes())
136            .map_err(|e| FaucetError::Config(format!("tls: invalid PEM client identity: {e}")))
137    }
138}
139
140/// Map a [`Credential`] from a shared provider onto the REST [`Auth`]
141/// representation so the existing header-application path can be reused.
142/// Substitute `${name}` tokens with flow-captured login values (#567). Only
143/// exact `${name}` occurrences for a captured `name` are replaced; any other
144/// `${...}` is left untouched. Applied to the URL and config header values so a
145/// captured session value can travel there per request.
146fn substitute_captured(s: &str, captured: &std::collections::BTreeMap<String, String>) -> String {
147    if captured.is_empty() || !s.contains("${") {
148        return s.to_string();
149    }
150    let mut out = s.to_string();
151    for (k, v) in captured {
152        out = out.replace(&format!("${{{k}}}"), v);
153    }
154    out
155}
156
157fn credential_to_auth(cred: Credential) -> Auth {
158    match cred {
159        Credential::Bearer(token) => Auth::Bearer { token },
160        Credential::Token(token) => Auth::Custom {
161            headers: std::iter::once(("Authorization".to_string(), token)).collect(),
162        },
163        Credential::Basic { username, password } => Auth::Basic { username, password },
164        Credential::Header { name, value } => Auth::Custom {
165            headers: std::iter::once((name, value)).collect(),
166        },
167    }
168}
169
170/// First JSONPath match rendered as a string (string verbatim, number as text).
171/// Used by the async-job runner to read the job id / status from responses.
172fn jsonpath_first_string(v: &Value, path: &str) -> Option<String> {
173    use jsonpath_rust::JsonPath;
174    let results = v.query(path).ok()?;
175    match results.first()? {
176        Value::String(s) => Some(s.clone()),
177        Value::Number(n) => Some(n.to_string()),
178        Value::Bool(b) => Some(b.to_string()),
179        _ => None,
180    }
181}
182
183/// First JSONPath match as an owned [`Value`] (type-preserving). Used by the
184/// resumable-cursor bookmark (#547) so a numeric cursor stays a number.
185fn jsonpath_first_value(v: &Value, path: &str) -> Option<Value> {
186    use jsonpath_rust::JsonPath;
187    v.query(path).ok()?.first().map(|x| (*x).clone())
188}
189
190/// A locator value counts as "no more pages" when it is empty or the literal
191/// string `null` (Salesforce Bulk sends `Sforce-Locator: null` when done).
192fn is_terminal_locator(value: &str) -> bool {
193    let v = value.trim();
194    v.is_empty() || v.eq_ignore_ascii_case("null")
195}
196
197/// Read the next result-set locator (#557) from the fetch response header or
198/// body, per the `fetch` config. Returns `None` when no locator source is
199/// configured or the locator signals completion.
200fn next_locator(
201    headers: &HeaderMap,
202    body: Option<&Value>,
203    job: &crate::async_job::AsyncJobConfig,
204) -> Option<String> {
205    if let Some(name) = &job.fetch.locator_header
206        && let Some(raw) = headers.get(name).and_then(|v| v.to_str().ok())
207        && !is_terminal_locator(raw)
208    {
209        return Some(raw.trim().to_string());
210    }
211    if let Some(path) = &job.fetch.locator_body
212        && let Some(body) = body
213        && let Some(raw) = jsonpath_first_string(body, path)
214        && !is_terminal_locator(&raw)
215    {
216        return Some(raw.trim().to_string());
217    }
218    None
219}
220
221/// Insert a header from string parts, mapping invalid names/values to a typed
222/// config error rather than panicking.
223fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(), FaucetError> {
224    let hn = HeaderName::from_bytes(name.as_bytes())
225        .map_err(|e| FaucetError::Config(format!("rest: invalid header name '{name}': {e}")))?;
226    let hv = HeaderValue::from_str(value).map_err(|e| {
227        FaucetError::Config(format!("rest: invalid value for header '{name}': {e}"))
228    })?;
229    headers.insert(hn, hv);
230    Ok(())
231}
232
233impl RestStream {
234    /// Create a new stream from the given configuration.
235    pub fn new(mut config: RestStreamConfig) -> Result<Self, FaucetError> {
236        // Derive OData request defaults (paging, `$.value`, query sugar, Prefer)
237        // before validation so the checks see the effective request shape.
238        config.apply_odata_defaults();
239        // Cross-field config invariants (e.g. file response formats can't paginate).
240        config.validate()?;
241        // Validate expiry_ratio at construction time.
242        let expiry_ratio_to_validate = match &config.auth {
243            AuthSpec::Inline(Auth::OAuth2 { expiry_ratio, .. })
244            | AuthSpec::Inline(Auth::TokenEndpoint { expiry_ratio, .. }) => Some(*expiry_ratio),
245            _ => None,
246        };
247        if let Some(ratio) = expiry_ratio_to_validate
248            && (ratio <= 0.0 || ratio > 1.0)
249        {
250            return Err(FaucetError::Auth(format!(
251                "expiry_ratio must be in (0.0, 1.0], got {ratio}"
252            )));
253        }
254
255        let mut builder = Client::builder();
256        if let Some(t) = config.timeout {
257            builder = builder.timeout(t);
258        }
259        // Mutual TLS: attach a client certificate/identity to the shared client
260        // so it is presented on every request — data pages AND any inline auth
261        // token request (both use `self.client`).
262        if let Some(tls) = &config.tls {
263            tls.validate()?;
264            builder = apply_client_tls(builder, tls)?;
265        }
266        // Build the default retry policy from REST's own legacy reliability
267        // fields so behavior is unchanged when no policy is injected. The REST
268        // `retry::execute_with_retry` runner is driven by `max_retries`
269        // (retries-after-first) + `base`, so `max_attempts = max_retries + 1`.
270        let retry_policy = faucet_core::RetryPolicy {
271            max_attempts: config.max_retries.saturating_add(1),
272            backoff: faucet_core::BackoffKind::Exponential,
273            base: config.retry_backoff,
274            ..faucet_core::RetryPolicy::default()
275        };
276        // Static custom headers (#539): validated once here (also validated in
277        // `config.validate()` above, so this cannot fail) and reused per request.
278        let static_headers = crate::config::build_header_map(&config.headers)?;
279        Ok(Self {
280            config,
281            client: builder.build()?,
282            token_cache: TokenCache::new(),
283            token_endpoint_cache: TokenEndpointCache::new(),
284            auth_provider: None,
285            runtime_start: Arc::new(AsyncMutex::new(None)),
286            window_binds: Arc::new(AsyncMutex::new(Vec::new())),
287            now_override: None,
288            retry_policy,
289            static_headers,
290        })
291    }
292
293    /// Attach a shared [`AuthProvider`](faucet_core::AuthProvider). When set, the
294    /// provider supplies the credential for every request (taking precedence
295    /// over inline auth), so several sources can share one token with
296    /// single-flight refresh. Used by the CLI to resolve `auth: { ref }`, and by
297    /// library callers who construct one provider and inject it into many
298    /// sources.
299    pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
300        self.auth_provider = Some(provider);
301        self
302    }
303
304    /// Test-only: pin the "now" upper bound used by datetime window slicing (#527)
305    /// to a fixed RFC 3339 instant, so the window enumeration is deterministic in
306    /// tests. No effect in production (which uses `Utc::now()`). Hidden from docs;
307    /// takes a string so callers need not depend on `chrono`.
308    #[doc(hidden)]
309    pub fn with_now_override_rfc3339(mut self, rfc3339: &str) -> Self {
310        self.now_override = chrono::DateTime::parse_from_rfc3339(rfc3339)
311            .ok()
312            .map(|d| d.with_timezone(&chrono::Utc));
313        self
314    }
315
316    /// Attach a custom [`RetryPolicy`](faucet_core::RetryPolicy) for transient
317    /// request failures, used by the CLI to inject a pipeline-level
318    /// `resilience:` policy.
319    ///
320    /// **Legacy-field precedence:** the REST connector predates the unified
321    /// resilience policy and exposes its own `max_retries` / `retry_backoff`
322    /// config fields. If the user has set either of those away from its default
323    /// (`max_retries: 3`, `retry_backoff: 1s`), those explicit values win and the
324    /// injected `policy` is ignored — an explicit per-connector setting is never
325    /// silently overridden by a pipeline-wide default. When both fields are at
326    /// their defaults, the injected policy takes effect.
327    ///
328    /// **Inert fields on REST:** because the REST source keeps its own
329    /// `429`/`Retry-After`-aware retry runner, it honors only the injected
330    /// policy's `max_attempts` (→ `max_retries`) and `base` (→ `retry_backoff`).
331    /// The policy's `max` (per-sleep cap), `jitter`, and `retry_on` fields are
332    /// **not** honored here — they apply on the `xml`/`graphql` sources and on
333    /// every sink-side write.
334    pub fn with_retry_policy(mut self, policy: faucet_core::RetryPolicy) -> Self {
335        let user_changed_legacy_fields = self.config.max_retries != DEFAULT_MAX_RETRIES
336            || self.config.retry_backoff != DEFAULT_RETRY_BACKOFF;
337        if !user_changed_legacy_fields {
338            self.retry_policy = policy;
339        }
340        self
341    }
342
343    /// Fetch all records across all pages as raw JSON values.
344    ///
345    /// When `partitions` are configured, the stream is executed once per
346    /// partition and all results are concatenated.
347    ///
348    /// When `replication_method` is `Incremental` and `replication_key` +
349    /// `start_replication_value` are both set, records at or before the
350    /// bookmark are filtered out.
351    pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
352        if self.config.partitions.is_empty() {
353            self.fetch_partition(None, None).await
354        } else if let Some(concurrency) = self.config.partition_concurrency {
355            // Process partitions concurrently using a semaphore to limit parallelism.
356            let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(concurrency.max(1)));
357            let mut handles = Vec::with_capacity(self.config.partitions.len());
358
359            for ctx in &self.config.partitions {
360                let permit =
361                    semaphore.clone().acquire_owned().await.map_err(|e| {
362                        FaucetError::Config(format!("semaphore acquire failed: {e}"))
363                    })?;
364                let fut = self.fetch_partition(Some(ctx), None);
365                handles.push(async move {
366                    let result = fut.await;
367                    drop(permit);
368                    result
369                });
370            }
371
372            let results = futures::future::try_join_all(handles).await?;
373            Ok(results.into_iter().flatten().collect())
374        } else {
375            let mut all_records = Vec::new();
376            for ctx in &self.config.partitions {
377                let records = self.fetch_partition(Some(ctx), None).await?;
378                all_records.extend(records);
379            }
380            Ok(all_records)
381        }
382    }
383
384    /// Fetch all records and deserialize into typed structs.
385    pub async fn fetch_all_as<T: for<'de> Deserialize<'de>>(&self) -> Result<Vec<T>, FaucetError> {
386        let values = self.fetch_all().await?;
387        values
388            .into_iter()
389            .map(|v| serde_json::from_value(v).map_err(FaucetError::Json))
390            .collect()
391    }
392
393    /// Infer a JSON Schema for this stream's records.
394    ///
395    /// If a `schema` is already set on the config, it is returned immediately
396    /// without making any HTTP requests.
397    ///
398    /// Otherwise the stream fetches up to `schema_sample_size` records
399    /// (respecting `max_pages`) and derives a JSON Schema from them.  Fields
400    /// that are absent in some records, or that carry a `null` value, are
401    /// marked as nullable (`["<type>", "null"]`).
402    ///
403    /// Set `schema_sample_size` to `0` to sample all available records.
404    pub async fn infer_schema(&self) -> Result<Value, FaucetError> {
405        if let Some(ref s) = self.config.schema {
406            return Ok(s.clone());
407        }
408        let limit = match self.config.schema_sample_size {
409            0 => None,
410            n => Some(n),
411        };
412        let records = self.fetch_partition(None, limit).await?;
413        Ok(schema::infer_schema(&records))
414    }
415
416    /// Fetch all records in incremental mode, returning the records along with
417    /// the maximum value of `replication_key` observed across those records.
418    ///
419    /// The returned bookmark should be persisted by the caller and passed back
420    /// as `start_replication_value` on the next run.
421    ///
422    /// If no `replication_key` is configured, this behaves identically to
423    /// [`fetch_all`](Self::fetch_all) and the bookmark is `None`.
424    pub async fn fetch_all_incremental(&self) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
425        let records = self.fetch_all().await?;
426        let bookmark = self
427            .config
428            .replication_key
429            .as_deref()
430            .and_then(|key| max_replication_value(&records, key))
431            .cloned();
432        Ok((records, bookmark))
433    }
434
435    /// Stream API pages without buffering the full result set.
436    ///
437    /// This is a thin convenience wrapper around the
438    /// [`Source::stream_pages`](faucet_core::Source::stream_pages) trait
439    /// method — it discards bookmarks and yields one `Vec<Value>` per
440    /// upstream API page. Use the trait method directly if you need
441    /// per-page bookmarks for incremental replication.
442    ///
443    /// Note: this inherent convenience method does not fan out over
444    /// `partitions`. The `Source::stream_pages` trait impl (what the pipeline
445    /// drives) and [`fetch_all`](Self::fetch_all) do handle multi-partition
446    /// streams (#535).
447    ///
448    /// ```rust,no_run
449    /// use faucet_source_rest::{RestStream, RestStreamConfig};
450    /// use futures::StreamExt;
451    ///
452    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
453    /// let stream = RestStream::new(RestStreamConfig::new("https://api.example.com", "/items"))?;
454    /// let mut pages = stream.stream_pages();
455    /// while let Some(page) = pages.next().await {
456    ///     let records = page?;
457    ///     println!("got {} records", records.len());
458    /// }
459    /// # Ok(())
460    /// # }
461    /// ```
462    pub fn stream_pages(
463        &self,
464    ) -> Pin<Box<dyn Stream<Item = Result<Vec<Value>, FaucetError>> + Send + '_>> {
465        let mut inner = self.stream_pages_inner(None);
466        Box::pin(async_stream::try_stream! {
467            loop {
468                let page = std::future::poll_fn(|cx| inner.as_mut().poll_next(cx)).await;
469                match page {
470                    Some(Ok(p)) => yield p.records,
471                    Some(Err(e)) => Err(e)?,
472                    None => break,
473                }
474            }
475        })
476    }
477
478    // ── Private helpers ───────────────────────────────────────────────────────
479
480    /// Extract a page's records from a parsed response body, honouring the
481    /// configured extraction mode: `records_multi` (#548, op-stamped multi-array
482    /// fan-out), `record_ancestors` (#549, nested path with lifted ancestor
483    /// fields), or the classic single `records_path`.
484    fn extract_page(&self, body: &Value) -> Result<Vec<Value>, FaucetError> {
485        extract::extract_configured(
486            body,
487            self.config.records_path.as_deref(),
488            self.config.record_ancestors.as_ref(),
489            &self.config.records_multi,
490            self.config.op_field.as_deref().unwrap_or("_op"),
491        )
492    }
493
494    /// Core pagination loop shared by [`Source::stream_pages`] and
495    /// [`fetch_partition`](Self::fetch_partition).
496    ///
497    /// Yields one [`faucet_core::StreamPage`] per page. The final page carries
498    /// the consolidated replication bookmark (`Some(value)`); all intermediate
499    /// pages carry `None`. When `context` is `Some`, path placeholders are
500    /// substituted for partition support.
501    fn stream_pages_inner(
502        &self,
503        context: Option<&HashMap<String, Value>>,
504    ) -> Pin<Box<dyn Stream<Item = Result<faucet_core::StreamPage, FaucetError>> + Send + '_>> {
505        // Clone the context into an owned map so it can live inside the
506        // `async_stream` generator without borrowing from the caller.
507        let owned_context: Option<HashMap<String, Value>> = context.cloned();
508
509        Box::pin(async_stream::try_stream! {
510            // Async-job lifecycle (#514): submit → poll → fetch replaces the
511            // normal single-GET + pagination flow and yields one result page.
512            if self.config.async_job.is_some() {
513                let records = self.run_async_job().await?;
514                yield faucet_core::StreamPage { records, bookmark: None };
515                return;
516            }
517
518            // Resolve the effective start-bookmark once at the top of the stream.
519            // A runtime override (applied via `Source::apply_start_bookmark` —
520            // typically by the pipeline reading from a `StateStore`) takes
521            // precedence over the static config value.
522            let effective_start: Option<Value> = {
523                let guard = self.runtime_start.lock().await;
524                guard
525                    .clone()
526                    .or_else(|| self.config.start_replication_value.clone())
527            };
528
529            // H13 (audit #146): combining `max_pages` with incremental
530            // replication only makes safe forward progress when the API returns
531            // rows ordered ascending by the replication key. On truncation we
532            // advance the bookmark to the max key seen so far (so the next run
533            // resumes past it — without this the stream would re-read the same
534            // first `max_pages` window forever and never progress); but if the
535            // feed is unordered, unfetched later pages may hold lower keys that
536            // resuming past `running_max` would then drop. Warn loudly so the
537            // requirement is explicit rather than a silent data-loss edge.
538            if self.config.max_pages.is_some()
539                && self.config.replication_method == ReplicationMethod::Incremental
540                && self.config.replication_key.is_some()
541            {
542                tracing::warn!(
543                    "max_pages combined with incremental replication assumes the API returns rows \
544                     ordered ascending by the replication key; an unordered feed can drop unfetched \
545                     lower-key records on resume. Ensure ordering, or remove max_pages for a full \
546                     incremental sweep."
547                );
548            }
549
550            // #527: build the pass plan. Without a `window:` block this is a
551            // single "unbounded" pass with the classic record-derived bookmark.
552            // With one, each rolling `[start, end)` window is its own pass whose
553            // bookmark is the window's end boundary — so a mid-sweep crash resumes
554            // from the last completed window (per-window durability).
555            let windowed = self.config.window.is_some();
556            let passes: Vec<Option<faucet_core::Window>> = if let Some(win) = &self.config.window {
557                let start_val = effective_start.clone().ok_or_else(|| {
558                    FaucetError::Config(
559                        "rest: `window` slicing requires a start bookmark (from a `state:` store) \
560                         or `start_replication_value` to anchor the first window".into(),
561                    )
562                })?;
563                let start_instant = faucet_core::parse_instant(&start_val)?;
564                let now = self.now_override.unwrap_or_else(chrono::Utc::now);
565                let step = win.step_duration()?;
566                let lookback = win.lookback_duration()?;
567                let (windows, truncated) =
568                    faucet_core::enumerate_windows(start_instant, now, step, lookback, win.max_windows);
569                if truncated {
570                    tracing::warn!(
571                        max_windows = win.max_windows,
572                        "window slicing hit `max_windows`; this run's sweep is truncated — the next \
573                         run resumes from the last completed window"
574                    );
575                }
576                if windows.is_empty() {
577                    tracing::debug!(
578                        "window slicing: the bookmark is at or ahead of now; nothing to fetch"
579                    );
580                }
581                windows.into_iter().map(Some).collect()
582            } else {
583                vec![None]
584            };
585
586            for pass in passes {
587                // Set the window bounds applied to every request in this pass
588                // (an unbounded pass leaves `window_binds` empty).
589                // `execute_request_once` reads `self.window_binds`.
590                if let Some(w) = &pass {
591                    let win = self
592                        .config
593                        .window
594                        .as_ref()
595                        .expect("a window pass implies a `window:` block");
596                    let lower = (win.lower.into, win.lower.name.clone(), win.render_lower(w));
597                    let upper_rendered = win.render_upper(w)?;
598                    let upper = (win.upper.into, win.upper.name.clone(), upper_rendered);
599                    *self.window_binds.lock().await = vec![lower, upper];
600                }
601
602                // The bookmark this pass persists on its final page: the window's
603                // end (a half-open boundary, so resume neither gaps nor overlaps)
604                // for a windowed pass, or the record-derived running max for the
605                // classic unbounded pass.
606                let window_bookmark: Option<Value> =
607                    pass.as_ref().map(|w| Value::String(w.end.to_rfc3339()));
608
609                let mut state = PaginationState::default();
610                // #547: on resume, seed the stored cursor into the first request
611                // (query param for `Cursor`, body field for `CursorInBody`).
612                if self.config.persist_cursor
613                    && let Some(seed) = effective_start.as_ref()
614                {
615                    state.next_token =
616                        Some(crate::pagination::value_to_param_string(seed));
617                }
618                let mut pages_fetched = 0usize;
619                let mut running_max: Option<Value> = effective_start.clone();
620                // #547: the terminal cursor to persist as this run's bookmark.
621                let mut running_cursor: Option<Value> = effective_start.clone();
622                let mut bookmark_emitted = false;
623
624                loop {
625                    if let Some(max) = self.config.max_pages
626                        && pages_fetched >= max
627                    {
628                        tracing::warn!("max pages ({max}) reached");
629                        break;
630                    }
631
632                    let mut params = self.config.query_params.clone();
633                    self.config.pagination.apply_params(&mut params, &state);
634
635                    let url_override = match &self.config.pagination {
636                        PaginationStyle::LinkHeader | PaginationStyle::NextLinkInBody { .. } => {
637                            state.next_link.clone()
638                        }
639                        _ => None,
640                    };
641
642                    // Body-carrying pagination (CursorInBody / OffsetInBody /
643                    // RecordFieldCursor into:body): fields injected into the
644                    // request JSON body for this page.
645                    let body_params = self.config.pagination.body_params(&state);
646
647                    let params_clone = params.clone();
648                    let ctx_ref = owned_context.as_ref();
649                    let is_first_page = pages_fetched == 0;
650                    let (body, resp_headers) = retry::execute_with_retry(
651                        // The REST runner takes retries-after-first; the policy holds
652                        // total attempts. Feed both knobs from the resolved policy so
653                        // an injected `resilience:` policy (when legacy fields are
654                        // untouched) governs the retry budget + base backoff while the
655                        // runner keeps its 429 / `Retry-After` handling.
656                        self.retry_policy.max_attempts.saturating_sub(1),
657                        self.retry_policy.base,
658                        || {
659                            self.execute_request(
660                                &params_clone,
661                                url_override.as_deref(),
662                                ctx_ref,
663                                is_first_page,
664                                &body_params,
665                            )
666                        },
667                    )
668                    .await?;
669
670                    let raw_records = self.extract_page(&body)?;
671                    let raw_count = raw_records.len();
672
673                    // #547: track the terminal cursor to persist as the bookmark.
674                    if self.config.persist_cursor
675                        && let Some(path) = self.config.pagination.cursor_path()
676                        && let Some(cursor) = jsonpath_first_value(&body, path)
677                    {
678                        match &cursor {
679                            Value::Null => {}
680                            Value::String(s) if s.is_empty() => {}
681                            _ => running_cursor = Some(cursor),
682                        }
683                    }
684
685                    // Client-side incremental filter. Skipped for windowed passes:
686                    // the server already bounds each window, and filtering by the
687                    // overall start would drop `lookback` rows that fall before it.
688                    let records = if !windowed
689                        && self.config.replication_method == ReplicationMethod::Incremental
690                    {
691                        if let (Some(key), Some(start)) =
692                            (&self.config.replication_key, effective_start.as_ref())
693                        {
694                            filter_incremental(raw_records, key, start)
695                        } else {
696                            raw_records
697                        }
698                    } else {
699                        raw_records
700                    };
701
702                    // Track the running max replication value across pages so the
703                    // final page of an unbounded pass can carry the consolidated
704                    // bookmark. When the replication bind declares `advance_from`,
705                    // the next bookmark is read from that JSONPath in the response
706                    // body (#513); otherwise it is `max(record[replication_key])`.
707                    // Windowed passes ignore this — their bookmark is the window end.
708                    if !windowed
709                        && self.config.replication_method == ReplicationMethod::Incremental
710                    {
711                        let page_max: Option<Value> = match self
712                            .config
713                            .replication_bind
714                            .as_ref()
715                            .and_then(|b| b.advance_from.as_deref())
716                        {
717                            Some(path) => faucet_core::util::extract_records(&body, Some(path))
718                                .ok()
719                                .and_then(|vs| vs.into_iter().next()),
720                            None => self
721                                .config
722                                .replication_key
723                                .as_deref()
724                                .and_then(|key| max_replication_value(&records, key).cloned()),
725                        };
726                        if let Some(page_max) = page_max {
727                            running_max = Some(match running_max.take() {
728                                Some(prev) => max_value(prev, page_max),
729                                None => page_max,
730                            });
731                        }
732                    }
733
734                    // #554: derive this page's keyset cursor (max/min of the
735                    // configured field) so the next request can page by it. A
736                    // no-op for every non-RecordFieldCursor style.
737                    self.config
738                        .pagination
739                        .update_record_cursor(&records, &mut state);
740
741                    // Advance pagination state to learn whether there is a next
742                    // page BEFORE yielding the current one. This way the bookmark
743                    // is only attached to pages where `has_next == false`, and we
744                    // never pre-fetch the next page just to classify the current
745                    // one as "final" (which would prevent early exit in callers
746                    // such as `fetch_partition` with `max_records`).
747                    let has_next = self
748                        .config
749                        .pagination
750                        .advance(&body, &resp_headers, &mut state, raw_count)?;
751                    pages_fetched += 1;
752
753                    if has_next {
754                        // Intermediate page — yield without bookmark so the
755                        // pipeline does not persist a partial checkpoint.
756                        yield faucet_core::StreamPage { records, bookmark: None };
757                    } else if state.current_page_is_duplicate {
758                        // The content-stagnation guard flagged this page as a
759                        // duplicate of the previous one — DROP it (do not emit the
760                        // repeated records to the sink) and stop. The trailing
761                        // bookmark checkpoint below still fires (#321 L1).
762                        break;
763                    } else {
764                        // Final page of this pass — attach the pass bookmark.
765                        let bookmark = if self.config.persist_cursor {
766                            running_cursor.clone()
767                        } else if windowed {
768                            window_bookmark.clone()
769                        } else {
770                            running_max.clone()
771                        };
772                        bookmark_emitted = bookmark.is_some();
773                        yield faucet_core::StreamPage { records, bookmark };
774                        break;
775                    }
776
777                    if let Some(delay) = self.config.request_delay {
778                        tokio::time::sleep(delay).await;
779                    }
780                }
781
782                // Trailing checkpoint: if the pass loop exited without carrying the
783                // bookmark on a real page (max_pages truncation, or a duplicate-page
784                // stop), emit one empty page carrying the pass bookmark so progress
785                // still persists and the next run resumes from here. (Safe forward
786                // progress under max_pages assumes ascending order by the
787                // replication key — see the warning emitted above, audit #146 H13.)
788                let pass_bookmark = if self.config.persist_cursor {
789                    running_cursor.clone()
790                } else if windowed {
791                    window_bookmark.clone()
792                } else {
793                    running_max.clone()
794                };
795                if !bookmark_emitted && pass_bookmark.is_some() {
796                    yield faucet_core::StreamPage {
797                        records: Vec::new(),
798                        bookmark: pass_bookmark,
799                    };
800                }
801            }
802
803            // Clear the window bounds so a reused source instance starts clean.
804            if windowed {
805                self.window_binds.lock().await.clear();
806            }
807        })
808    }
809
810    /// Run the full pagination loop for a single partition context.
811    ///
812    /// `max_records`: when `Some(n)`, stop collecting after `n` records
813    /// (used for schema sampling).
814    async fn fetch_partition(
815        &self,
816        context: Option<&HashMap<String, Value>>,
817        max_records: Option<usize>,
818    ) -> Result<Vec<Value>, FaucetError> {
819        let mut all_records = Vec::new();
820        let mut pages_fetched = 0usize;
821        let mut pages = self.stream_pages_inner(context);
822
823        // Poll the stream without requiring StreamExt (avoids extra dependency).
824        loop {
825            let page = std::future::poll_fn(|cx: &mut std::task::Context<'_>| {
826                pages.as_mut().poll_next(cx)
827            })
828            .await;
829
830            match page {
831                Some(Ok(page)) => {
832                    pages_fetched += 1;
833                    let records = page.records;
834                    match max_records {
835                        Some(limit) => {
836                            let remaining = limit.saturating_sub(all_records.len());
837                            all_records.extend(records.into_iter().take(remaining));
838                            if all_records.len() >= limit {
839                                break;
840                            }
841                        }
842                        None => all_records.extend(records),
843                    }
844                }
845                Some(Err(e)) => return Err(e),
846                None => break,
847            }
848        }
849
850        tracing::info!(
851            stream = self.config.name.as_deref().unwrap_or("(unnamed)"),
852            records = all_records.len(),
853            pages = pages_fetched,
854            "fetch complete"
855        );
856        Ok(all_records)
857    }
858
859    /// Execute a request, transparently refreshing an inline OAuth2 /
860    /// TokenEndpoint token once on a 401.
861    ///
862    /// The cached token's validity is tracked purely by the server-reported
863    /// `expires_in` (and a token with no `expires_in` is cached as valid
864    /// forever), so a *server-side* expiry surfaces only as a 401 on a real
865    /// request. The documented contract is "valid until a 401 forces a
866    /// refresh" — so on a 401 with an inline cached token we invalidate the
867    /// cache and retry exactly once with a freshly-fetched token (F57). Shared
868    /// auth providers manage their own refresh and are not retried here.
869    async fn execute_request(
870        &self,
871        params: &HashMap<String, String>,
872        url_override: Option<&str>,
873        path_context: Option<&HashMap<String, Value>>,
874        is_first_page: bool,
875        body_params: &[(String, Value)],
876    ) -> Result<(Value, HeaderMap), FaucetError> {
877        match self
878            .execute_request_once(
879                params,
880                url_override,
881                path_context,
882                is_first_page,
883                body_params,
884            )
885            .await
886        {
887            Err(FaucetError::HttpStatus { status: 401, .. }) if self.uses_inline_cached_token() => {
888                tracing::warn!(
889                    "401 Unauthorized with a cached inline OAuth2/TokenEndpoint token; \
890                     invalidating the token cache and retrying once with a fresh token"
891                );
892                self.invalidate_inline_token_cache().await;
893                self.execute_request_once(
894                    params,
895                    url_override,
896                    path_context,
897                    is_first_page,
898                    body_params,
899                )
900                .await
901            }
902            // #511: a shared provider (e.g. a multi-step flow) whose session
903            // expired mid-run — re-auth on a status it declared in `reauth_on`
904            // and retry once.
905            Err(FaucetError::HttpStatus { status, .. }) if self.provider_wants_reauth(status) => {
906                if let Some(provider) = &self.auth_provider {
907                    tracing::warn!(
908                        status,
909                        "shared auth provider requested re-auth on this status; \
910                         re-authenticating and retrying once"
911                    );
912                    let _ = provider.invalidate(&Credential::Token(String::new())).await;
913                }
914                self.execute_request_once(
915                    params,
916                    url_override,
917                    path_context,
918                    is_first_page,
919                    body_params,
920                )
921                .await
922            }
923            other => other,
924        }
925    }
926
927    /// `true` when a shared provider declared `status` in its `reauth_statuses`.
928    fn provider_wants_reauth(&self, status: u16) -> bool {
929        self.auth_provider
930            .as_ref()
931            .is_some_and(|p| p.reauth_statuses().contains(&status))
932    }
933
934    /// `true` when this source resolves its bearer token from one of the inline
935    /// time-cached auth modes (no shared provider) — the only case where a 401
936    /// should trigger a cache invalidation + retry (F57).
937    fn uses_inline_cached_token(&self) -> bool {
938        self.auth_provider.is_none()
939            && matches!(
940                self.config.auth,
941                AuthSpec::Inline(Auth::OAuth2 { .. })
942                    | AuthSpec::Inline(Auth::TokenEndpoint { .. })
943            )
944    }
945
946    /// Invalidate whichever inline token cache backs the current auth mode, so
947    /// the next request fetches a fresh token (F57).
948    async fn invalidate_inline_token_cache(&self) {
949        match &self.config.auth {
950            AuthSpec::Inline(Auth::OAuth2 { .. }) => self.token_cache.invalidate().await,
951            AuthSpec::Inline(Auth::TokenEndpoint { .. }) => {
952                self.token_endpoint_cache.invalidate().await
953            }
954            _ => {}
955        }
956    }
957
958    /// Resolve the server-side push-down binding for this run:
959    /// `(target, name, rendered-value)`. Returns `None` when no `replication_bind`
960    /// is configured or there is no bookmark yet (first run — a full pull).
961    async fn resolved_bind(&self) -> Result<Option<(BindTarget, String, String)>, FaucetError> {
962        let Some(bind) = &self.config.replication_bind else {
963            return Ok(None);
964        };
965        let bookmark = {
966            let guard = self.runtime_start.lock().await;
967            guard.clone()
968        }
969        .or_else(|| self.config.start_replication_value.clone());
970        match bookmark {
971            Some(bm) => Ok(Some((bind.into, bind.name.clone(), bind.render(&bm)?))),
972            None => Ok(None),
973        }
974    }
975
976    /// Build + send one job-lifecycle request (auth via `metadata_headers`,
977    /// plus the connector's static headers and the request's own headers/query/
978    /// json). Returns the raw response bytes; errors on non-2xx.
979    async fn job_request_bytes(
980        &self,
981        method: &str,
982        url: &str,
983        headers: &HashMap<String, String>,
984        query: &HashMap<String, String>,
985        json: Option<&Value>,
986    ) -> Result<(Vec<u8>, HeaderMap), FaucetError> {
987        let m = reqwest::Method::from_bytes(method.to_uppercase().as_bytes()).map_err(|_| {
988            FaucetError::Config(format!("async_job: invalid HTTP method '{method}'"))
989        })?;
990        // Precedence: static config headers (base) < auth < this request's own
991        // headers — so an auth header always wins over a same-named config one.
992        let mut hdrs = self.static_headers.clone();
993        for (k, v) in self.metadata_headers(url).await?.iter() {
994            hdrs.insert(k.clone(), v.clone());
995        }
996        for (k, v) in headers {
997            insert_header(&mut hdrs, k, v)?;
998        }
999        let mut req = self.client.request(m, url).headers(hdrs);
1000        if !query.is_empty() {
1001            let pairs: Vec<(&str, &str)> = query
1002                .iter()
1003                .map(|(k, v)| (k.as_str(), v.as_str()))
1004                .collect();
1005            req = req.query(&pairs);
1006        }
1007        if let Some(j) = json {
1008            req = req.json(j);
1009        }
1010        let resp = req
1011            .send()
1012            .await
1013            .map_err(|e| FaucetError::Source(format!("async_job: request to {url} failed: {e}")))?;
1014        let status = resp.status();
1015        if !status.is_success() {
1016            return Err(FaucetError::HttpStatus {
1017                status: status.as_u16(),
1018                url: url.to_string(),
1019                body: format!("async_job: {url} returned HTTP {}", status.as_u16()),
1020            });
1021        }
1022        let resp_headers = resp.headers().clone();
1023        Ok((resp.bytes().await?.to_vec(), resp_headers))
1024    }
1025
1026    async fn job_request_json(
1027        &self,
1028        method: &str,
1029        url: &str,
1030        headers: &HashMap<String, String>,
1031        query: &HashMap<String, String>,
1032        json: Option<&Value>,
1033    ) -> Result<Value, FaucetError> {
1034        let (bytes, _headers) = self
1035            .job_request_bytes(method, url, headers, query, json)
1036            .await?;
1037        serde_json::from_slice(&bytes)
1038            .map_err(|e| FaucetError::Source(format!("async_job: {url} returned non-JSON: {e}")))
1039    }
1040
1041    /// Run the submit → poll → fetch job lifecycle (#514) and return the
1042    /// decoded result records.
1043    async fn run_async_job(&self) -> Result<Vec<Value>, FaucetError> {
1044        use crate::async_job::{JobOutcome, resolve_url, substitute_job_id};
1045        let job = self
1046            .config
1047            .async_job
1048            .as_ref()
1049            .expect("run_async_job called with async_job set");
1050        let base = &self.config.base_url;
1051
1052        // 1) Submit → capture the job id.
1053        let submit_url = resolve_url(base, job.submit.url.as_deref().unwrap_or_default());
1054        let submit_body = self
1055            .job_request_json(
1056                &job.submit.method,
1057                &submit_url,
1058                &job.submit.headers,
1059                &job.submit.query,
1060                job.submit.json.as_ref(),
1061            )
1062            .await?;
1063        let job_id = jsonpath_first_string(&submit_body, &job.job_id).ok_or_else(|| {
1064            FaucetError::Source(format!(
1065                "async_job: submit response had no job id at '{}'",
1066                job.job_id
1067            ))
1068        })?;
1069
1070        // 2) Poll until a terminal state (with interval + timeout).
1071        let poll_url = resolve_url(base, &substitute_job_id(&job.poll.url, &job_id));
1072        let deadline =
1073            tokio::time::Instant::now() + std::time::Duration::from_secs(job.poll.timeout_secs);
1074        // Retain the last poll response so `fetch.url_from` (#543) can source the
1075        // download URL from the terminal (success) poll body.
1076        let last_poll_body: Value = loop {
1077            let body = self
1078                .job_request_json(
1079                    &job.poll.method,
1080                    &poll_url,
1081                    &job.poll.headers,
1082                    &job.poll.query,
1083                    None,
1084                )
1085                .await?;
1086            let status = jsonpath_first_string(&body, &job.status.path).unwrap_or_default();
1087            match job.status.classify(&status) {
1088                JobOutcome::Success => break body,
1089                JobOutcome::Failure => {
1090                    return Err(FaucetError::Source(format!(
1091                        "async_job: job failed with status '{status}'"
1092                    )));
1093                }
1094                JobOutcome::Pending => {
1095                    if tokio::time::Instant::now() >= deadline {
1096                        return Err(FaucetError::Source(format!(
1097                            "async_job: polling timed out after {}s (last status '{status}')",
1098                            job.poll.timeout_secs
1099                        )));
1100                    }
1101                    tokio::time::sleep(std::time::Duration::from_secs(job.poll.interval_secs))
1102                        .await;
1103                }
1104            }
1105        };
1106
1107        // 3) Resolve the fetch URL (#543): from the poll body via `url_from`, or
1108        // by rendering the templated `url`. Exactly one is set (validated).
1109        let fetch_url = match (&job.fetch.url_from, &job.fetch.url) {
1110            (Some(path), _) => {
1111                let resolved = jsonpath_first_string(&last_poll_body, path).ok_or_else(|| {
1112                    FaucetError::Source(format!(
1113                        "async_job: fetch.url_from '{path}' matched no string in the poll response"
1114                    ))
1115                })?;
1116                resolve_url(base, &resolved)
1117            }
1118            (None, Some(url)) => resolve_url(base, &substitute_job_id(url, &job_id)),
1119            (None, None) => {
1120                return Err(FaucetError::Config(
1121                    "async_job: `fetch` requires exactly one of `url` or `url_from`".into(),
1122                ));
1123            }
1124        };
1125
1126        // 4) Fetch the result and decode it — looping across locator-paged result
1127        // sets (#557) when a `locator_header` / `locator_body` is configured.
1128        // Without a locator this runs exactly once (the classic single fetch).
1129        let mut all_records = Vec::new();
1130        let mut locator: Option<String> = None;
1131        loop {
1132            // Send the locator (when we have one) as the configured query param.
1133            let mut query = job.fetch.query.clone();
1134            if let (Some(loc), Some(param)) = (&locator, &job.fetch.locator_param) {
1135                query.insert(param.clone(), loc.clone());
1136            }
1137            let (bytes, resp_headers) = self
1138                .job_request_bytes(
1139                    &job.fetch.method,
1140                    &fetch_url,
1141                    &job.fetch.headers,
1142                    &query,
1143                    job.fetch.json.as_ref(),
1144                )
1145                .await?;
1146            let (records, body_value) = self.parse_fetch_page(&bytes, job).await?;
1147            all_records.extend(records);
1148
1149            // Determine the next locator from the header or the body; stop when
1150            // it is absent, empty, `"null"`, or repeats (loop guard).
1151            let next = next_locator(&resp_headers, body_value.as_ref(), job);
1152            match next {
1153                Some(loc) if locator.as_deref() != Some(loc.as_str()) => {
1154                    locator = Some(loc);
1155                }
1156                _ => break,
1157            }
1158        }
1159        Ok(all_records)
1160    }
1161
1162    /// Parse one async-job fetch page into records, returning the parsed JSON
1163    /// body too (for `locator_body` extraction) when the result is JSON. Mirrors
1164    /// the single-fetch parsing: a `decode:` pipeline wins, else `response_format`
1165    /// (JSON honouring `fetch.records_path` or the source `records_path`).
1166    async fn parse_fetch_page(
1167        &self,
1168        bytes: &[u8],
1169        job: &crate::async_job::AsyncJobConfig,
1170    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1171        if !self.config.decode.is_empty() {
1172            let records = crate::decode::run_decode(bytes, &self.config.decode).await?;
1173            return Ok((records, None));
1174        }
1175        match self.config.response_format {
1176            crate::config::ResponseFormat::Json => {
1177                let v: Value = serde_json::from_slice(bytes).map_err(|e| {
1178                    FaucetError::Source(format!("async_job: result is not JSON: {e}"))
1179                })?;
1180                let records = match job.fetch.records_path.as_deref() {
1181                    Some(rp) => extract::extract_records(&v, Some(rp))?,
1182                    None => self.extract_page(&v)?,
1183                };
1184                Ok((records, Some(v)))
1185            }
1186            crate::config::ResponseFormat::Csv => {
1187                let records = crate::format::parse_csv(
1188                    bytes,
1189                    self.config.csv_delimiter,
1190                    self.config.csv_has_headers,
1191                )
1192                .await?;
1193                Ok((records, None))
1194            }
1195            crate::config::ResponseFormat::Excel => {
1196                let records = crate::format::parse_excel(
1197                    bytes,
1198                    self.config.excel_sheet.as_deref(),
1199                    self.config.excel_header_row,
1200                )?;
1201                Ok((records, None))
1202            }
1203        }
1204    }
1205
1206    /// Resolve auth headers for a non-paginated preflight request (OData
1207    /// `$metadata`). Applies a flow provider's header/cookie placements or its
1208    /// credential; else the inline auth (bearer via cache for OAuth2/token
1209    /// endpoint). Query/body placements and `ApiKeyQuery` are not applied here.
1210    async fn metadata_headers(&self, url: &str) -> Result<HeaderMap, FaucetError> {
1211        let mut headers = HeaderMap::new();
1212        if let Some(provider) = &self.auth_provider {
1213            let ra = provider
1214                .request_auth("GET", url, &std::collections::BTreeMap::new())
1215                .await?;
1216            if ra.is_empty() {
1217                credential_to_auth(provider.credential().await?).apply(&mut headers)?;
1218            } else {
1219                for p in ra.placements {
1220                    match p {
1221                        CredentialPlacement::Header { name, value } => {
1222                            insert_header(&mut headers, &name, &value)?
1223                        }
1224                        CredentialPlacement::Cookie { name, value } => {
1225                            insert_header(&mut headers, "Cookie", &format!("{name}={value}"))?
1226                        }
1227                        _ => {}
1228                    }
1229                }
1230            }
1231        } else {
1232            match &self.config.auth {
1233                AuthSpec::Inline(Auth::OAuth2 {
1234                    token_url,
1235                    client_id,
1236                    client_secret,
1237                    scopes,
1238                    expiry_ratio,
1239                }) => {
1240                    let token = self
1241                        .token_cache
1242                        .get_or_refresh(
1243                            &self.client,
1244                            token_url,
1245                            client_id,
1246                            client_secret,
1247                            scopes,
1248                            *expiry_ratio,
1249                        )
1250                        .await?;
1251                    Auth::Bearer { token }.apply(&mut headers)?;
1252                }
1253                AuthSpec::Inline(Auth::TokenEndpoint {
1254                    url: token_url,
1255                    method: token_method,
1256                    headers: token_headers,
1257                    body: token_body,
1258                    token_path,
1259                    expiry_path,
1260                    expiry_ratio,
1261                    response_validator,
1262                }) => {
1263                    let token = self
1264                        .token_endpoint_cache
1265                        .get_or_refresh(
1266                            &self.client,
1267                            token_url,
1268                            token_method,
1269                            token_headers,
1270                            token_body.as_ref(),
1271                            token_path,
1272                            expiry_path.as_deref(),
1273                            *expiry_ratio,
1274                            response_validator.as_ref(),
1275                        )
1276                        .await?;
1277                    Auth::Bearer { token }.apply(&mut headers)?;
1278                }
1279                AuthSpec::Inline(other) => other.apply(&mut headers)?,
1280                AuthSpec::Reference(_) => {}
1281            }
1282        }
1283        Ok(headers)
1284    }
1285
1286    /// Execute a single HTTP request and return the response body and headers.
1287    ///
1288    /// - When `url_override` is `Some`, that full URL is used and query params
1289    ///   are **not** appended (Link header pagination encodes them in the URL).
1290    /// - When `path_context` is `Some`, `{key}` placeholders in `config.path`
1291    ///   are substituted with values from the context map (partition support).
1292    async fn execute_request_once(
1293        &self,
1294        params: &HashMap<String, String>,
1295        url_override: Option<&str>,
1296        path_context: Option<&HashMap<String, Value>>,
1297        is_first_page: bool,
1298        body_params: &[(String, Value)],
1299    ) -> Result<(Value, HeaderMap), FaucetError> {
1300        let use_override = url_override.is_some();
1301
1302        // #513 server-side push-down + #527 window slicing: the outgoing request
1303        // carries the bookmark binding (0 or 1) plus the current window's rendered
1304        // lower/upper bounds (0 or 2). They apply at the same four placement sites.
1305        let mut binds: Vec<(BindTarget, String, String)> = Vec::new();
1306        if let Some(b) = self.resolved_bind().await? {
1307            binds.push(b);
1308        }
1309        binds.extend(self.window_binds.lock().await.iter().cloned());
1310
1311        let query_btree: std::collections::BTreeMap<String, String> =
1312            params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1313
1314        // #511 rich per-request auth: a flow provider may place credentials
1315        // across header/query/cookie/body and override the base-URL for this
1316        // session. When it contributes anything, it supersedes the plain
1317        // credential()/sign_request() path below.
1318        let mut base_url = self.config.base_url.clone();
1319        let mut ra_headers: Vec<(String, String)> = Vec::new();
1320        let mut ra_query: Vec<(String, String)> = Vec::new();
1321        let mut ra_cookies: Vec<(String, String)> = Vec::new();
1322        let mut ra_body: Vec<(String, String)> = Vec::new();
1323        let mut captured: std::collections::BTreeMap<String, String> =
1324            std::collections::BTreeMap::new();
1325        let mut used_request_auth = false;
1326        if let Some(provider) = &self.auth_provider {
1327            let ra = provider
1328                .request_auth(self.config.method.as_str(), &base_url, &query_btree)
1329                .await?;
1330            if !ra.is_empty() {
1331                used_request_auth = true;
1332                if let Some(b) = ra.base_url {
1333                    base_url = b;
1334                }
1335                captured = ra.captured;
1336                for p in ra.placements {
1337                    match p {
1338                        CredentialPlacement::Header { name, value } => {
1339                            ra_headers.push((name, value))
1340                        }
1341                        CredentialPlacement::Query { name, value } => ra_query.push((name, value)),
1342                        CredentialPlacement::Cookie { name, value } => {
1343                            ra_cookies.push((name, value))
1344                        }
1345                        CredentialPlacement::BodyField { name, value } => {
1346                            ra_body.push((name, value))
1347                        }
1348                        _ => {}
1349                    }
1350                }
1351            }
1352        }
1353
1354        // Build the URL (honouring any dynamic base-URL) and apply a `path`-target
1355        // push-down binding.
1356        let mut url = match url_override {
1357            Some(u) => u.to_string(),
1358            None => {
1359                let path = match path_context {
1360                    Some(ctx) => faucet_core::util::substitute_context(&self.config.path, ctx),
1361                    None => self.config.path.clone(),
1362                };
1363                format!("{}/{}", base_url, path.trim_start_matches('/'))
1364            }
1365        };
1366        for (target, name, rendered) in &binds {
1367            if *target == BindTarget::Path {
1368                url = url.replace(&format!("{{{name}}}"), rendered);
1369            }
1370        }
1371        // #567: substitute flow-captured `${name}` values into the URL (a
1372        // captured session id in the path, say). No-op when nothing was captured.
1373        url = substitute_captured(&url, &captured);
1374
1375        // Resolve inline / signed credentials — unless a flow provider already
1376        // supplied the request auth. A shared provider (from `auth: { ref }` or
1377        // a library caller) takes precedence over inline; inline OAuth2 /
1378        // TokenEndpoint resolve to a Bearer token via the per-source cache.
1379        let resolved_auth: Option<Auth> = if used_request_auth {
1380            None
1381        } else if let Some(provider) = &self.auth_provider {
1382            // A per-request signer (OAuth1, #496) signs this exact method + URL +
1383            // query; every other provider returns `None` here and we apply its
1384            // reusable credential.
1385            let cred = match provider
1386                .sign_request(self.config.method.as_str(), &url, &query_btree)
1387                .await?
1388            {
1389                Some(cred) => cred,
1390                None => provider.credential().await?,
1391            };
1392            Some(credential_to_auth(cred))
1393        } else {
1394            match &self.config.auth {
1395                AuthSpec::Inline(Auth::OAuth2 {
1396                    token_url,
1397                    client_id,
1398                    client_secret,
1399                    scopes,
1400                    expiry_ratio,
1401                }) => {
1402                    let token = self
1403                        .token_cache
1404                        .get_or_refresh(
1405                            &self.client,
1406                            token_url,
1407                            client_id,
1408                            client_secret,
1409                            scopes,
1410                            *expiry_ratio,
1411                        )
1412                        .await?;
1413                    Some(Auth::Bearer { token })
1414                }
1415                AuthSpec::Inline(Auth::TokenEndpoint {
1416                    url: token_url,
1417                    method: token_method,
1418                    headers: token_headers,
1419                    body: token_body,
1420                    token_path,
1421                    expiry_path,
1422                    expiry_ratio,
1423                    response_validator,
1424                }) => {
1425                    let token = self
1426                        .token_endpoint_cache
1427                        .get_or_refresh(
1428                            &self.client,
1429                            token_url,
1430                            token_method,
1431                            token_headers,
1432                            token_body.as_ref(),
1433                            token_path,
1434                            expiry_path.as_deref(),
1435                            *expiry_ratio,
1436                            response_validator.as_ref(),
1437                        )
1438                        .await?;
1439                    Some(Auth::Bearer { token })
1440                }
1441                AuthSpec::Inline(other) => Some(other.clone()),
1442                AuthSpec::Reference(r) => {
1443                    return Err(FaucetError::Auth(format!(
1444                        "auth references provider '{}' but no provider was supplied; \
1445                         set one via the CLI `auth:` catalog or `with_auth_provider`",
1446                        r.name
1447                    )));
1448                }
1449            }
1450        };
1451
1452        // Static config headers form the base; auth (inline or provider) is
1453        // applied on top so an auth header of the same name wins (#539). A
1454        // flow-captured `${name}` in a header value is substituted per request
1455        // (#567); the map is empty (and this a plain clone) for non-flow auth.
1456        let mut headers = if captured.is_empty() {
1457            self.static_headers.clone()
1458        } else {
1459            let mut h = HeaderMap::new();
1460            for (name, value) in self.static_headers.iter() {
1461                let sv = substitute_captured(value.to_str().unwrap_or_default(), &captured);
1462                let hv =
1463                    reqwest::header::HeaderValue::from_str(&sv).unwrap_or_else(|_| value.clone());
1464                h.insert(name.clone(), hv);
1465            }
1466            h
1467        };
1468        if let Some(auth) = &resolved_auth {
1469            auth.apply(&mut headers)?;
1470        }
1471        // #511 header + cookie placements from the flow provider.
1472        for (name, value) in &ra_headers {
1473            insert_header(&mut headers, name, value)?;
1474        }
1475        if !ra_cookies.is_empty() {
1476            let cookie = ra_cookies
1477                .iter()
1478                .map(|(k, v)| format!("{k}={v}"))
1479                .collect::<Vec<_>>()
1480                .join("; ");
1481            insert_header(&mut headers, "Cookie", &cookie)?;
1482        }
1483        // #513/#527 header-target bindings.
1484        for (target, name, rendered) in &binds {
1485            if *target == BindTarget::Header {
1486                insert_header(&mut headers, name, rendered)?;
1487            }
1488        }
1489
1490        let mut req = self
1491            .client
1492            .request(self.config.method.clone(), &url)
1493            .headers(headers);
1494
1495        if !use_override {
1496            // When parent context is available, substitute {placeholders} in
1497            // query param values so child sources can be parameterised.
1498            if let Some(ctx) = path_context {
1499                let substituted: HashMap<String, String> = params
1500                    .iter()
1501                    .map(|(k, v)| (k.clone(), faucet_core::util::substitute_context(v, ctx)))
1502                    .collect();
1503                req = req.query(&substituted.iter().collect::<Vec<_>>());
1504            } else {
1505                req = req.query(params);
1506            }
1507            // #536: repeated / array-valued query params, rendered as repeated
1508            // keys (`?k=a&k=b`). reqwest's `.query()` appends, so this composes
1509            // with the scalar params above.
1510            if !self.config.query_params_multi.is_empty() {
1511                let pairs: Vec<(String, String)> = self
1512                    .config
1513                    .query_params_multi
1514                    .iter()
1515                    .flat_map(|(k, vals)| {
1516                        vals.iter().map(move |v| {
1517                            let rendered = match path_context {
1518                                Some(ctx) => faucet_core::util::substitute_context(v, ctx),
1519                                None => v.clone(),
1520                            };
1521                            (k.clone(), rendered)
1522                        })
1523                    })
1524                    .collect();
1525                req = req.query(
1526                    &pairs
1527                        .iter()
1528                        .map(|(k, v)| (k.as_str(), v.as_str()))
1529                        .collect::<Vec<_>>(),
1530                );
1531            }
1532        }
1533        // #511 query placements from the flow provider.
1534        if !ra_query.is_empty() {
1535            let pairs: Vec<(&str, &str)> = ra_query
1536                .iter()
1537                .map(|(k, v)| (k.as_str(), v.as_str()))
1538                .collect();
1539            req = req.query(&pairs);
1540        }
1541        // #513/#527 query-target bindings.
1542        for (target, name, rendered) in &binds {
1543            if *target == BindTarget::Query {
1544                req = req.query(&[(name.as_str(), rendered.as_str())]);
1545            }
1546        }
1547
1548        // ApiKeyQuery: inject the API key as a query parameter.
1549        if let AuthSpec::Inline(Auth::ApiKeyQuery { param, value }) = &self.config.auth {
1550            req = req.query(&[(param.as_str(), value.as_str())]);
1551        }
1552
1553        // Build the request JSON body, if any. Substitute context into body
1554        // string values when available. Use the JSON-safe variant:
1555        // `substitute_context` does NOT escape the value, so a context value
1556        // carrying a JSON metacharacter (`"`, `\`, newline) corrupts the
1557        // serialized body — the old `unwrap_or(Value::String(..))` fallback then
1558        // silently coerced the whole object into a bare string and POSTed garbage
1559        // (audit #321 H7). `substitute_context_json` JSON-escapes string values;
1560        // an un-parseable result is now a hard error rather than a silently-wrong
1561        // payload.
1562        let mut body_value: Option<Value> = match &self.config.body {
1563            Some(body) => match path_context {
1564                Some(ctx) => {
1565                    let body_str = body.to_string();
1566                    let substituted = faucet_core::util::substitute_context_json(&body_str, ctx);
1567                    let substituted_value: Value =
1568                        serde_json::from_str(&substituted).map_err(|e| {
1569                            FaucetError::Source(format!(
1570                                "REST source: context substitution produced an invalid JSON body: {e}"
1571                            ))
1572                        })?;
1573                    Some(substituted_value)
1574                }
1575                None => Some(body.clone()),
1576            },
1577            None => None,
1578        };
1579        // Body-carrying pagination (CursorInBody / OffsetInBody / RecordFieldCursor
1580        // with `into: body`): inject the pagination fields into the request body.
1581        // If no base body was configured, start from an empty object so the
1582        // fields still land somewhere.
1583        if !body_params.is_empty() {
1584            let obj = body_value.get_or_insert_with(|| Value::Object(serde_json::Map::new()));
1585            match obj.as_object_mut() {
1586                Some(map) => {
1587                    for (field, value) in body_params {
1588                        map.insert(field.clone(), value.clone());
1589                    }
1590                }
1591                None => {
1592                    return Err(FaucetError::Source(
1593                        "REST source: body-carrying pagination requires a JSON object request \
1594                         body to inject the pagination fields into"
1595                            .into(),
1596                    ));
1597                }
1598            }
1599        }
1600        // #511 body-field placements + #513/#527 body-target bindings.
1601        let has_body_bind = binds.iter().any(|(t, _, _)| *t == BindTarget::Body);
1602        if !ra_body.is_empty() || has_body_bind {
1603            let obj = body_value.get_or_insert_with(|| Value::Object(serde_json::Map::new()));
1604            match obj.as_object_mut() {
1605                Some(map) => {
1606                    for (name, value) in &ra_body {
1607                        map.insert(name.clone(), Value::String(value.clone()));
1608                    }
1609                    for (target, name, rendered) in &binds {
1610                        if *target == BindTarget::Body {
1611                            map.insert(name.clone(), Value::String(rendered.clone()));
1612                        }
1613                    }
1614                }
1615                None => {
1616                    return Err(FaucetError::Source(
1617                        "REST source: a body-target auth/replication binding requires a JSON \
1618                         object request body"
1619                            .into(),
1620                    ));
1621                }
1622            }
1623        }
1624        if let Some(body) = &body_value {
1625            req = req.json(body);
1626        }
1627
1628        let resp = req.send().await?;
1629        let status = resp.status();
1630
1631        // 429 Too Many Requests: honour Retry-After before retrying.
1632        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
1633            let wait = parse_retry_after(resp.headers());
1634            return Err(FaucetError::RateLimited(wait));
1635        }
1636
1637        // Tolerated errors: treat as an empty page ONLY on the first request,
1638        // where they legitimately mean "this resource is absent/empty". Mid-
1639        // pagination, an empty page makes every pagination style read "last
1640        // page" and stop, silently dropping every remaining page as a
1641        // "successful" run (#78/#7). There we fall through to the real error
1642        // path: the retry executor retries 5xx, and a persistent error fails
1643        // loudly instead of truncating the stream.
1644        if is_first_page && self.config.tolerated_http_errors.contains(&status.as_u16()) {
1645            tracing::debug!(
1646                status = status.as_u16(),
1647                "tolerated HTTP error on first request; treating as empty page"
1648            );
1649            return Ok((Value::Array(vec![]), HeaderMap::new()));
1650        }
1651        if !is_first_page && self.config.tolerated_http_errors.contains(&status.as_u16()) {
1652            tracing::warn!(
1653                status = status.as_u16(),
1654                "tolerated HTTP error mid-pagination; surfacing as an error to avoid \
1655                 silently truncating the stream"
1656            );
1657        }
1658
1659        // For non-success responses, capture the body for debugging before
1660        // returning the error. This gives callers (and logs) the server's
1661        // error message rather than just a status code.
1662        if !status.is_success() {
1663            // Redact any auth secret carried in the query string before it lands
1664            // in the error (which renders the URL in `Display` → logs). The
1665            // `api_key_query` value is user-configured, so it is not marked
1666            // sensitive like a Bearer/Basic header and would otherwise leak on
1667            // any 4xx/5xx (audit #321 L2).
1668            let resp_url = redact_error_url(resp.url(), &self.config.auth);
1669            let body_text = resp.text().await.unwrap_or_default();
1670            // Truncate very long error bodies to avoid bloating logs/errors.
1671            let truncated = if body_text.len() > 1024 {
1672                // Find a safe UTF-8 boundary at or before 1024 bytes.
1673                let end = body_text.floor_char_boundary(1024);
1674                format!("{}...(truncated)", &body_text[..end])
1675            } else {
1676                body_text
1677            };
1678            return Err(FaucetError::HttpStatus {
1679                status: status.as_u16(),
1680                url: resp_url,
1681                body: truncated,
1682            });
1683        }
1684
1685        let resp_headers = resp.headers().clone();
1686
1687        // A 204 No Content — or any 2xx with an empty / whitespace-only body —
1688        // carries no JSON to parse. `resp.json()` on such a response yields a
1689        // non-retriable decode error ("EOF while parsing a value") that aborts
1690        // the run; treat it as an empty page ("no data") instead (#146 M10). A
1691        // non-empty body that isn't valid JSON still surfaces as a parse error.
1692        if status == reqwest::StatusCode::NO_CONTENT {
1693            return Ok((Value::Array(vec![]), resp_headers));
1694        }
1695        let bytes = resp.bytes().await?;
1696        if bytes.iter().all(u8::is_ascii_whitespace) {
1697            return Ok((Value::Array(vec![]), resp_headers));
1698        }
1699        // A `decode:` pipeline (#515) takes the raw body and produces records
1700        // directly (extract → base64 → gunzip/unzip → parse). It replaces the
1701        // `response_format` parsing; `validate()` guarantees pagination is
1702        // `none`. The records land as an array the downstream
1703        // (records_path-less) extraction passes straight through.
1704        if !self.config.decode.is_empty() {
1705            let records = crate::decode::run_decode(&bytes, &self.config.decode).await?;
1706            return Ok((Value::Array(records), resp_headers));
1707        }
1708        // For file response formats the whole body is a tabular file — parse it
1709        // into a record array here so the downstream (records_path-less)
1710        // extraction passes it straight through. `validate()` guarantees
1711        // pagination is `none`, so a single response is fetched.
1712        let body: Value = match self.config.response_format {
1713            crate::config::ResponseFormat::Json => serde_json::from_slice(&bytes)?,
1714            crate::config::ResponseFormat::Csv => Value::Array(
1715                crate::format::parse_csv(
1716                    &bytes,
1717                    self.config.csv_delimiter,
1718                    self.config.csv_has_headers,
1719                )
1720                .await?,
1721            ),
1722            crate::config::ResponseFormat::Excel => Value::Array(crate::format::parse_excel(
1723                &bytes,
1724                self.config.excel_sheet.as_deref(),
1725                self.config.excel_header_row,
1726            )?),
1727        };
1728        Ok((body, resp_headers))
1729    }
1730}
1731
1732/// Render a response URL for an error message with any auth secret in the query
1733/// string redacted (audit #321 L2). Redacts the user-configured
1734/// `api_key_query` parameter by name (which `redact_uri_credentials` cannot
1735/// know), then applies the shared credential/query-secret redaction for the
1736/// common key names and any URL userinfo.
1737fn redact_error_url(url: &reqwest::Url, auth: &AuthSpec<Auth>) -> String {
1738    let mut redacted = url.clone();
1739    if let AuthSpec::Inline(Auth::ApiKeyQuery { param, .. }) = auth {
1740        let pairs: Vec<(String, String)> = url
1741            .query_pairs()
1742            .map(|(k, v)| {
1743                if k == param.as_str() {
1744                    (k.into_owned(), "***".to_string())
1745                } else {
1746                    (k.into_owned(), v.into_owned())
1747                }
1748            })
1749            .collect();
1750        redacted.set_query(None);
1751        if !pairs.is_empty() {
1752            let mut qp = redacted.query_pairs_mut();
1753            for (k, v) in &pairs {
1754                qp.append_pair(k, v);
1755            }
1756        }
1757    }
1758    faucet_core::redact_uri_credentials(redacted.as_str())
1759}
1760
1761/// Parse the `Retry-After` header. RFC 7231 permits **either** delta-seconds
1762/// **or** an HTTP-date; we honour both. An HTTP-date in the past yields a zero
1763/// wait (retry now). Falls back to 60 s only when the header is absent or in
1764/// neither form.
1765fn parse_retry_after(headers: &HeaderMap) -> Duration {
1766    const DEFAULT: Duration = Duration::from_secs(60);
1767    let Some(raw) = headers
1768        .get(reqwest::header::RETRY_AFTER)
1769        .and_then(|v| v.to_str().ok())
1770        .map(str::trim)
1771    else {
1772        return DEFAULT;
1773    };
1774    // delta-seconds form.
1775    if let Ok(secs) = raw.parse::<u64>() {
1776        return Duration::from_secs(secs);
1777    }
1778    // HTTP-date form (IMF-fixdate / RFC 850 / asctime).
1779    if let Ok(when) = httpdate::parse_http_date(raw) {
1780        return when
1781            .duration_since(std::time::SystemTime::now())
1782            .unwrap_or(Duration::ZERO);
1783    }
1784    DEFAULT
1785}
1786
1787/// Keep the larger of two bookmark values when consolidating per-partition
1788/// bookmarks in [`Source::stream_pages`] (#535). Numbers compare numerically,
1789/// strings lexicographically (the usual timestamp/id bookmark shapes); any
1790/// other or heterogeneous pair prefers the newer value.
1791fn value_max(current: Option<Value>, candidate: Value) -> Option<Value> {
1792    match current {
1793        None => Some(candidate),
1794        Some(cur) => {
1795            let take_candidate = match (&cur, &candidate) {
1796                (Value::Number(a), Value::Number(b)) => {
1797                    b.as_f64().unwrap_or(f64::MIN) > a.as_f64().unwrap_or(f64::MIN)
1798                }
1799                (Value::String(a), Value::String(b)) => b > a,
1800                _ => true,
1801            };
1802            Some(if take_candidate { candidate } else { cur })
1803        }
1804    }
1805}
1806
1807#[async_trait]
1808impl faucet_core::Source for RestStream {
1809    async fn fetch_with_context(
1810        &self,
1811        context: &std::collections::HashMap<String, serde_json::Value>,
1812    ) -> Result<Vec<Value>, FaucetError> {
1813        if context.is_empty() {
1814            // No parent context — use normal fetch_all with partitions
1815            RestStream::fetch_all(self).await
1816        } else if self.config.partitions.is_empty() {
1817            // Parent context, no partitions — use context directly as partition context
1818            self.fetch_partition(Some(context), None).await
1819        } else {
1820            // Both parent context and partitions — merge context into each partition
1821            let mut all_records = Vec::new();
1822            for partition in &self.config.partitions {
1823                let mut merged = context.clone();
1824                merged.extend(partition.iter().map(|(k, v)| (k.clone(), v.clone())));
1825                all_records.extend(self.fetch_partition(Some(&merged), None).await?);
1826            }
1827            Ok(all_records)
1828        }
1829    }
1830
1831    async fn fetch_with_context_incremental(
1832        &self,
1833        context: &std::collections::HashMap<String, serde_json::Value>,
1834    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1835        let records = self.fetch_with_context(context).await?;
1836        let bookmark = self
1837            .config
1838            .replication_key
1839            .as_deref()
1840            .and_then(|key| faucet_core::replication::max_replication_value(&records, key))
1841            .cloned();
1842        Ok((records, bookmark))
1843    }
1844
1845    fn connector_name(&self) -> &'static str {
1846        "rest"
1847    }
1848
1849    fn config_schema(&self) -> serde_json::Value {
1850        serde_json::to_value(faucet_core::schema_for!(RestStreamConfig))
1851            .expect("schema serialization")
1852    }
1853
1854    fn dataset_uri(&self) -> String {
1855        format!(
1856            "{}{}",
1857            faucet_core::redact_uri_credentials(&self.config.base_url),
1858            self.config.path
1859        )
1860    }
1861
1862    fn state_key(&self) -> Option<String> {
1863        self.config.state_key.clone()
1864    }
1865
1866    fn stream_pages<'a>(
1867        &'a self,
1868        context: &'a HashMap<String, Value>,
1869        _batch_size: usize,
1870    ) -> Pin<Box<dyn Stream<Item = Result<faucet_core::StreamPage, FaucetError>> + Send + 'a>> {
1871        // RestStream chunks by upstream-API page boundaries, not by an
1872        // in-memory `batch_size` knob. The arg is accepted for trait
1873        // conformance and reserved for a future `page_size` mapping.
1874        //
1875        // Partition fan-out (#535): when `partitions` are configured the stream
1876        // must run once per partition — mirroring `fetch_all` / `fetch_with_context`
1877        // — or every partition's records are silently dropped under `faucet run`
1878        // (the pipeline drives this method). Any parent `context` is merged into
1879        // each partition context, exactly as `fetch_with_context` does.
1880        if self.config.partitions.is_empty() {
1881            return self.stream_pages_inner(Some(context));
1882        }
1883        let contexts: Vec<HashMap<String, Value>> = self
1884            .config
1885            .partitions
1886            .iter()
1887            .map(|p| {
1888                let mut merged = context.clone();
1889                merged.extend(p.iter().map(|(k, v)| (k.clone(), v.clone())));
1890                merged
1891            })
1892            .collect();
1893        Box::pin(async_stream::try_stream! {
1894            // Per-partition streams each emit their own final bookmark; we
1895            // suppress those and emit a single consolidated (max) bookmark after
1896            // the last partition, so the persisted state is the global high-water
1897            // mark rather than whichever partition happened to finish last.
1898            let mut max_bookmark: Option<Value> = None;
1899            for ctx in &contexts {
1900                let mut inner = self.stream_pages_inner(Some(ctx));
1901                loop {
1902                    let page = std::future::poll_fn(|cx| inner.as_mut().poll_next(cx)).await;
1903                    match page {
1904                        Some(Ok(p)) => {
1905                            if let Some(bm) = p.bookmark {
1906                                max_bookmark = value_max(max_bookmark.take(), bm);
1907                                yield faucet_core::StreamPage { records: p.records, bookmark: None };
1908                            } else {
1909                                yield p;
1910                            }
1911                        }
1912                        Some(Err(e)) => Err(e)?,
1913                        None => break,
1914                    }
1915                }
1916            }
1917            if max_bookmark.is_some() {
1918                yield faucet_core::StreamPage { records: Vec::new(), bookmark: max_bookmark };
1919            }
1920        })
1921    }
1922
1923    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
1924        *self.runtime_start.lock().await = Some(bookmark);
1925        Ok(())
1926    }
1927
1928    fn supports_discover(&self) -> bool {
1929        // OData exposes a machine-readable `$metadata` catalog; a plain REST API
1930        // has none, so discovery is OData-only.
1931        self.config.odata.is_some()
1932    }
1933
1934    async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
1935        if self.config.odata.is_none() {
1936            return Err(FaucetError::Source(
1937                "rest: discovery is only supported for OData sources — set an `odata:` block"
1938                    .into(),
1939            ));
1940        }
1941        let url = format!("{}/$metadata", self.config.base_url.trim_end_matches('/'));
1942        // Static config headers (#539) form the base; auth is applied on top.
1943        let mut headers = self.static_headers.clone();
1944        for (k, v) in self.metadata_headers(&url).await?.iter() {
1945            headers.insert(k.clone(), v.clone());
1946        }
1947        let resp = self
1948            .client
1949            .get(&url)
1950            .headers(headers)
1951            .send()
1952            .await
1953            .map_err(|e| {
1954                FaucetError::Source(format!("rest: OData $metadata request failed: {e}"))
1955            })?;
1956        let status = resp.status();
1957        if !status.is_success() {
1958            return Err(FaucetError::Source(format!(
1959                "rest: OData $metadata returned HTTP {}",
1960                status.as_u16()
1961            )));
1962        }
1963        let xml = resp.text().await.map_err(|e| {
1964            FaucetError::Source(format!("rest: reading OData $metadata failed: {e}"))
1965        })?;
1966        crate::odata::descriptors_from_edmx(&xml)
1967    }
1968}
1969
1970#[cfg(test)]
1971mod tests {
1972    use super::*;
1973    use serde_json::json;
1974
1975    #[test]
1976    fn value_max_consolidates_partition_bookmarks() {
1977        // First value seeds the max.
1978        assert_eq!(
1979            value_max(None, json!("2026-01-01")),
1980            Some(json!("2026-01-01"))
1981        );
1982        // Strings compare lexicographically (ISO timestamps sort correctly).
1983        assert_eq!(
1984            value_max(Some(json!("2026-01-01")), json!("2026-03-01")),
1985            Some(json!("2026-03-01"))
1986        );
1987        assert_eq!(
1988            value_max(Some(json!("2026-03-01")), json!("2026-01-01")),
1989            Some(json!("2026-03-01"))
1990        );
1991        // Numbers compare numerically.
1992        assert_eq!(value_max(Some(json!(5)), json!(10)), Some(json!(10)));
1993        assert_eq!(value_max(Some(json!(10)), json!(5)), Some(json!(10)));
1994        // Heterogeneous / other → prefer the latest candidate.
1995        assert_eq!(value_max(Some(json!("a")), json!(3)), Some(json!(3)));
1996    }
1997
1998    #[test]
1999    fn injected_policy_applies_when_legacy_fields_at_defaults() {
2000        // Config left at the default max_retries/retry_backoff → injection wins.
2001        let stream =
2002            RestStream::new(RestStreamConfig::new("https://api.example.com", "/items")).unwrap();
2003        let injected = faucet_core::RetryPolicy {
2004            max_attempts: 9,
2005            base: Duration::from_secs(7),
2006            ..faucet_core::RetryPolicy::default()
2007        };
2008        let stream = stream.with_retry_policy(injected);
2009        assert_eq!(stream.retry_policy.max_attempts, 9);
2010        assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
2011    }
2012
2013    #[test]
2014    fn legacy_fields_take_precedence_over_injected_policy() {
2015        // User set max_retries explicitly → the injected policy is ignored and
2016        // the connector's own legacy fields keep governing retries.
2017        let config = RestStreamConfig::new("https://api.example.com", "/items").max_retries(7);
2018        let stream = RestStream::new(config).unwrap();
2019        // Default policy derived from legacy fields: max_attempts = 7 + 1.
2020        assert_eq!(stream.retry_policy.max_attempts, 8);
2021        let injected = faucet_core::RetryPolicy {
2022            max_attempts: 99,
2023            base: Duration::from_secs(42),
2024            ..faucet_core::RetryPolicy::default()
2025        };
2026        let stream = stream.with_retry_policy(injected);
2027        // Unchanged: the legacy max_retries(7) still wins.
2028        assert_eq!(stream.retry_policy.max_attempts, 8);
2029        assert_eq!(stream.retry_policy.base, DEFAULT_RETRY_BACKOFF);
2030    }
2031
2032    #[test]
2033    fn redact_error_url_hides_api_key_query_param() {
2034        // #321 L2: a custom `api_key_query` param name is redacted by name.
2035        let auth = AuthSpec::Inline(Auth::ApiKeyQuery {
2036            param: "api_token".into(),
2037            value: "SUPERSECRET".into(),
2038        });
2039        let url =
2040            reqwest::Url::parse("https://api.example.com/v1/items?page=2&api_token=SUPERSECRET")
2041                .unwrap();
2042        let redacted = redact_error_url(&url, &auth);
2043        assert!(
2044            !redacted.contains("SUPERSECRET"),
2045            "secret must be gone: {redacted}"
2046        );
2047        assert!(redacted.contains("api_token=%2A%2A%2A") || redacted.contains("api_token=***"));
2048        assert!(
2049            redacted.contains("page=2"),
2050            "non-secret param kept: {redacted}"
2051        );
2052    }
2053
2054    #[test]
2055    fn redact_error_url_without_api_key_query_still_scrubs_common_keys() {
2056        // Non-ApiKeyQuery auth: the shared redaction still strips common secret
2057        // query keys and userinfo.
2058        let auth: AuthSpec<Auth> = AuthSpec::Inline(Auth::None);
2059        let url = reqwest::Url::parse("https://u:pw@api.example.com/v1/items?token=abc").unwrap();
2060        let redacted = redact_error_url(&url, &auth);
2061        assert!(
2062            !redacted.contains("abc"),
2063            "common secret key redacted: {redacted}"
2064        );
2065        assert!(!redacted.contains("pw@"), "userinfo redacted: {redacted}");
2066    }
2067
2068    #[test]
2069    fn test_substitute_context_substitutes_placeholders() {
2070        let mut ctx = HashMap::new();
2071        ctx.insert("org_id".to_string(), json!("acme"));
2072        ctx.insert("repo".to_string(), json!("myrepo"));
2073        let result =
2074            faucet_core::util::substitute_context("/orgs/{org_id}/repos/{repo}/issues", &ctx);
2075        assert_eq!(result, "/orgs/acme/repos/myrepo/issues");
2076    }
2077
2078    #[test]
2079    fn test_substitute_context_no_placeholders() {
2080        let ctx = HashMap::new();
2081        let result = faucet_core::util::substitute_context("/api/users", &ctx);
2082        assert_eq!(result, "/api/users");
2083    }
2084
2085    #[test]
2086    fn test_substitute_context_numeric_value() {
2087        let mut ctx = HashMap::new();
2088        ctx.insert("id".to_string(), json!(42));
2089        let result = faucet_core::util::substitute_context("/items/{id}", &ctx);
2090        assert_eq!(result, "/items/42");
2091    }
2092
2093    #[test]
2094    fn test_parse_retry_after_valid() {
2095        let mut headers = HeaderMap::new();
2096        headers.insert(
2097            reqwest::header::RETRY_AFTER,
2098            reqwest::header::HeaderValue::from_static("30"),
2099        );
2100        assert_eq!(parse_retry_after(&headers), Duration::from_secs(30));
2101    }
2102
2103    #[test]
2104    fn test_parse_retry_after_missing_defaults_to_60() {
2105        assert_eq!(
2106            parse_retry_after(&HeaderMap::new()),
2107            Duration::from_secs(60)
2108        );
2109    }
2110
2111    #[test]
2112    fn test_parse_retry_after_non_numeric_defaults_to_60() {
2113        let mut headers = HeaderMap::new();
2114        headers.insert(
2115            reqwest::header::RETRY_AFTER,
2116            reqwest::header::HeaderValue::from_static("not-a-number"),
2117        );
2118        assert_eq!(parse_retry_after(&headers), Duration::from_secs(60));
2119    }
2120
2121    #[test]
2122    fn test_parse_retry_after_http_date() {
2123        // RFC 7231 permits an HTTP-date form instead of delta-seconds.
2124        let future = std::time::SystemTime::now() + Duration::from_secs(7200);
2125        let date = httpdate::fmt_http_date(future);
2126        let mut headers = HeaderMap::new();
2127        headers.insert(
2128            reqwest::header::RETRY_AFTER,
2129            reqwest::header::HeaderValue::from_str(&date).unwrap(),
2130        );
2131        let d = parse_retry_after(&headers);
2132        // ~2 hours out — must not collapse to the 60s fallback.
2133        assert!(
2134            d > Duration::from_secs(3600),
2135            "expected ~2h from HTTP-date, got {d:?}"
2136        );
2137        assert!(
2138            d <= Duration::from_secs(7200),
2139            "should not exceed the target instant, got {d:?}"
2140        );
2141    }
2142
2143    #[test]
2144    fn test_parse_retry_after_past_http_date_is_zero() {
2145        // A date already in the past → retry now (zero wait), not the fallback.
2146        let past = std::time::SystemTime::now() - Duration::from_secs(3600);
2147        let date = httpdate::fmt_http_date(past);
2148        let mut headers = HeaderMap::new();
2149        headers.insert(
2150            reqwest::header::RETRY_AFTER,
2151            reqwest::header::HeaderValue::from_str(&date).unwrap(),
2152        );
2153        assert_eq!(parse_retry_after(&headers), Duration::ZERO);
2154    }
2155
2156    #[test]
2157    fn test_new_rejects_invalid_expiry_ratio_zero() {
2158        let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
2159            token_url: "https://auth.example.com/token".into(),
2160            client_id: "id".into(),
2161            client_secret: "secret".into(),
2162            scopes: vec![],
2163            expiry_ratio: 0.0,
2164        });
2165        let result = RestStream::new(config);
2166        assert!(result.is_err());
2167        assert!(matches!(result, Err(FaucetError::Auth(_))));
2168    }
2169
2170    #[test]
2171    fn test_new_rejects_invalid_expiry_ratio_negative() {
2172        let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
2173            token_url: "https://auth.example.com/token".into(),
2174            client_id: "id".into(),
2175            client_secret: "secret".into(),
2176            scopes: vec![],
2177            expiry_ratio: -0.5,
2178        });
2179        assert!(RestStream::new(config).is_err());
2180    }
2181
2182    #[test]
2183    fn test_new_rejects_invalid_expiry_ratio_above_one() {
2184        let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
2185            token_url: "https://auth.example.com/token".into(),
2186            client_id: "id".into(),
2187            client_secret: "secret".into(),
2188            scopes: vec![],
2189            expiry_ratio: 1.5,
2190        });
2191        assert!(RestStream::new(config).is_err());
2192    }
2193
2194    #[test]
2195    fn test_new_accepts_valid_expiry_ratio() {
2196        let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
2197            token_url: "https://auth.example.com/token".into(),
2198            client_id: "id".into(),
2199            client_secret: "secret".into(),
2200            scopes: vec![],
2201            expiry_ratio: 1.0,
2202        });
2203        assert!(RestStream::new(config).is_ok());
2204    }
2205
2206    #[test]
2207    fn test_new_with_no_auth_succeeds() {
2208        let config = RestStreamConfig::new("https://example.com", "/data");
2209        assert!(RestStream::new(config).is_ok());
2210    }
2211
2212    #[test]
2213    fn test_new_with_timeout() {
2214        let config =
2215            RestStreamConfig::new("https://example.com", "/data").timeout(Duration::from_secs(10));
2216        assert!(RestStream::new(config).is_ok());
2217    }
2218
2219    #[test]
2220    fn test_substitute_context_missing_placeholder_unchanged() {
2221        let mut ctx = HashMap::new();
2222        ctx.insert("org".to_string(), json!("acme"));
2223        let result = faucet_core::util::substitute_context("/items/{missing}", &ctx);
2224        assert_eq!(result, "/items/{missing}");
2225    }
2226
2227    #[test]
2228    fn test_substitute_context_boolean_value() {
2229        let mut ctx = HashMap::new();
2230        ctx.insert("flag".to_string(), json!(true));
2231        let result = faucet_core::util::substitute_context("/items/{flag}", &ctx);
2232        assert_eq!(result, "/items/true");
2233    }
2234
2235    #[test]
2236    fn rest_source_connector_name_is_rest() {
2237        use faucet_core::Source;
2238        let source = RestStream::new(RestStreamConfig::new("https://example.com", "/data"))
2239            .expect("minimal RestStream construction");
2240        assert_eq!(source.connector_name(), "rest");
2241    }
2242
2243    #[test]
2244    fn dataset_uri_combines_base_and_path() {
2245        use faucet_core::Source;
2246        let source = RestStream::new(RestStreamConfig::new(
2247            "https://api.example.com",
2248            "/v1/users",
2249        ))
2250        .unwrap();
2251        assert_eq!(source.dataset_uri(), "https://api.example.com/v1/users");
2252    }
2253
2254    #[test]
2255    fn dataset_uri_redacts_credentials() {
2256        use faucet_core::Source;
2257        let source = RestStream::new(RestStreamConfig::new(
2258            "https://user:secret@api.example.com",
2259            "/v1/data",
2260        ))
2261        .unwrap();
2262        assert_eq!(source.dataset_uri(), "https://api.example.com/v1/data");
2263    }
2264}
2265
2266/// Mutual-TLS unit tests (#495). Lib-level so llvm-cov attributes coverage of
2267/// `apply_client_tls` / `build_identity` / the `new()` TLS branch reliably.
2268#[cfg(all(test, feature = "mtls"))]
2269mod mtls_tests {
2270    use super::*;
2271    use crate::config::TlsClientConfig;
2272
2273    const CERT: &str = include_str!("../tests/fixtures/mtls/cert.pem");
2274    const KEY: &str = include_str!("../tests/fixtures/mtls/key.pem");
2275
2276    fn pem() -> TlsClientConfig {
2277        TlsClientConfig {
2278            client_cert: Some(CERT.to_string()),
2279            client_key: Some(KEY.to_string()),
2280            ..Default::default()
2281        }
2282    }
2283
2284    #[test]
2285    fn pem_identity_builds() {
2286        let cfg = RestStreamConfig::new("https://x.test", "/y").tls(pem());
2287        assert!(RestStream::new(cfg).is_ok());
2288    }
2289
2290    #[test]
2291    fn min_version_branches_are_exercised() {
2292        // 1.2 is universally supported and must build.
2293        let mut tls = pem();
2294        tls.min_version = Some("1.2".into());
2295        assert!(RestStream::new(RestStreamConfig::new("https://x.test", "/y").tls(tls)).is_ok());
2296        // 1.3 exercises the other branch; some native-tls backends (e.g. macOS
2297        // SecureTransport) reject a 1.3 floor at client-build time, so only
2298        // require it not to panic.
2299        let mut tls = pem();
2300        tls.min_version = Some("1.3".into());
2301        let _ = RestStream::new(RestStreamConfig::new("https://x.test", "/y").tls(tls));
2302    }
2303
2304    #[test]
2305    fn pkcs12_identity_builds() {
2306        let p12 = concat!(
2307            env!("CARGO_MANIFEST_DIR"),
2308            "/tests/fixtures/mtls/identity.p12"
2309        );
2310        let tls = TlsClientConfig {
2311            client_identity_pkcs12: Some(p12.to_string()),
2312            pkcs12_password: Some("changeit".into()),
2313            ..Default::default()
2314        };
2315        let cfg = RestStreamConfig::new("https://x.test", "/y").tls(tls);
2316        assert!(RestStream::new(cfg).is_ok());
2317    }
2318
2319    #[test]
2320    fn invalid_pem_errors_without_leaking_key() {
2321        let tls = TlsClientConfig {
2322            client_cert: Some("-----BEGIN CERTIFICATE-----\nbad\n-----END CERTIFICATE-----".into()),
2323            client_key: Some("SUPERSECRETKEY".into()),
2324            ..Default::default()
2325        };
2326        let cfg = RestStreamConfig::new("https://x.test", "/y").tls(tls);
2327        let err = RestStream::new(cfg)
2328            .map(|_| ())
2329            .expect_err("bad PEM must error");
2330        assert!(!err.to_string().contains("SUPERSECRETKEY"));
2331    }
2332
2333    #[test]
2334    fn missing_pkcs12_file_errors() {
2335        let tls = TlsClientConfig {
2336            client_identity_pkcs12: Some("/no/such.p12".into()),
2337            pkcs12_password: Some("x".into()),
2338            ..Default::default()
2339        };
2340        let cfg = RestStreamConfig::new("https://x.test", "/y").tls(tls);
2341        assert!(RestStream::new(cfg).is_err());
2342    }
2343}