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