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;
7use crate::extract;
8use crate::pagination::{PaginationState, PaginationStyle};
9use crate::retry;
10use async_trait::async_trait;
11use faucet_core::replication::{
12    ReplicationMethod, filter_incremental, max_replication_value, max_value,
13};
14use faucet_core::schema;
15use faucet_core::{AuthSpec, Credential, FaucetError, SharedAuthProvider};
16use futures_core::Stream;
17use reqwest::Client;
18use reqwest::header::HeaderMap;
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    /// Retry policy for transient request failures. Built in `new()` from the
46    /// REST source's own `config.max_retries` / `config.retry_backoff`. Fed into
47    /// the REST `retry::execute_with_retry` runner (which keeps its 429 /
48    /// `Retry-After` handling). Overridable via
49    /// [`with_retry_policy`](Self::with_retry_policy) — but the REST connector's
50    /// own legacy `max_retries` / `retry_backoff` fields take precedence when the
51    /// user has set them away from their defaults.
52    retry_policy: faucet_core::RetryPolicy,
53}
54
55/// Default value of [`RestStreamConfig::max_retries`]. When the user leaves this
56/// untouched, an injected [`RetryPolicy`](faucet_core::RetryPolicy) is allowed to
57/// override it (see [`RestStream::with_retry_policy`]).
58const DEFAULT_MAX_RETRIES: u32 = 3;
59/// Default value of [`RestStreamConfig::retry_backoff`]. Same precedence rule as
60/// [`DEFAULT_MAX_RETRIES`].
61const DEFAULT_RETRY_BACKOFF: Duration = Duration::from_secs(1);
62
63/// Map a [`Credential`] from a shared provider onto the REST [`Auth`]
64/// representation so the existing header-application path can be reused.
65fn credential_to_auth(cred: Credential) -> Auth {
66    match cred {
67        Credential::Bearer(token) => Auth::Bearer { token },
68        Credential::Token(token) => Auth::Custom {
69            headers: std::iter::once(("Authorization".to_string(), token)).collect(),
70        },
71        Credential::Basic { username, password } => Auth::Basic { username, password },
72        Credential::Header { name, value } => Auth::Custom {
73            headers: std::iter::once((name, value)).collect(),
74        },
75    }
76}
77
78impl RestStream {
79    /// Create a new stream from the given configuration.
80    pub fn new(config: RestStreamConfig) -> Result<Self, FaucetError> {
81        // Validate expiry_ratio at construction time.
82        let expiry_ratio_to_validate = match &config.auth {
83            AuthSpec::Inline(Auth::OAuth2 { expiry_ratio, .. })
84            | AuthSpec::Inline(Auth::TokenEndpoint { expiry_ratio, .. }) => Some(*expiry_ratio),
85            _ => None,
86        };
87        if let Some(ratio) = expiry_ratio_to_validate
88            && (ratio <= 0.0 || ratio > 1.0)
89        {
90            return Err(FaucetError::Auth(format!(
91                "expiry_ratio must be in (0.0, 1.0], got {ratio}"
92            )));
93        }
94
95        let mut builder = Client::builder();
96        if let Some(t) = config.timeout {
97            builder = builder.timeout(t);
98        }
99        // Build the default retry policy from REST's own legacy reliability
100        // fields so behavior is unchanged when no policy is injected. The REST
101        // `retry::execute_with_retry` runner is driven by `max_retries`
102        // (retries-after-first) + `base`, so `max_attempts = max_retries + 1`.
103        let retry_policy = faucet_core::RetryPolicy {
104            max_attempts: config.max_retries.saturating_add(1),
105            backoff: faucet_core::BackoffKind::Exponential,
106            base: config.retry_backoff,
107            ..faucet_core::RetryPolicy::default()
108        };
109        Ok(Self {
110            config,
111            client: builder.build()?,
112            token_cache: TokenCache::new(),
113            token_endpoint_cache: TokenEndpointCache::new(),
114            auth_provider: None,
115            runtime_start: Arc::new(AsyncMutex::new(None)),
116            retry_policy,
117        })
118    }
119
120    /// Attach a shared [`AuthProvider`](faucet_core::AuthProvider). When set, the
121    /// provider supplies the credential for every request (taking precedence
122    /// over inline auth), so several sources can share one token with
123    /// single-flight refresh. Used by the CLI to resolve `auth: { ref }`, and by
124    /// library callers who construct one provider and inject it into many
125    /// sources.
126    pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
127        self.auth_provider = Some(provider);
128        self
129    }
130
131    /// Attach a custom [`RetryPolicy`](faucet_core::RetryPolicy) for transient
132    /// request failures, used by the CLI to inject a pipeline-level
133    /// `resilience:` policy.
134    ///
135    /// **Legacy-field precedence:** the REST connector predates the unified
136    /// resilience policy and exposes its own `max_retries` / `retry_backoff`
137    /// config fields. If the user has set either of those away from its default
138    /// (`max_retries: 3`, `retry_backoff: 1s`), those explicit values win and the
139    /// injected `policy` is ignored — an explicit per-connector setting is never
140    /// silently overridden by a pipeline-wide default. When both fields are at
141    /// their defaults, the injected policy takes effect.
142    ///
143    /// **Inert fields on REST:** because the REST source keeps its own
144    /// `429`/`Retry-After`-aware retry runner, it honors only the injected
145    /// policy's `max_attempts` (→ `max_retries`) and `base` (→ `retry_backoff`).
146    /// The policy's `max` (per-sleep cap), `jitter`, and `retry_on` fields are
147    /// **not** honored here — they apply on the `xml`/`graphql` sources and on
148    /// every sink-side write.
149    pub fn with_retry_policy(mut self, policy: faucet_core::RetryPolicy) -> Self {
150        let user_changed_legacy_fields = self.config.max_retries != DEFAULT_MAX_RETRIES
151            || self.config.retry_backoff != DEFAULT_RETRY_BACKOFF;
152        if !user_changed_legacy_fields {
153            self.retry_policy = policy;
154        }
155        self
156    }
157
158    /// Fetch all records across all pages as raw JSON values.
159    ///
160    /// When `partitions` are configured, the stream is executed once per
161    /// partition and all results are concatenated.
162    ///
163    /// When `replication_method` is `Incremental` and `replication_key` +
164    /// `start_replication_value` are both set, records at or before the
165    /// bookmark are filtered out.
166    pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
167        if self.config.partitions.is_empty() {
168            self.fetch_partition(None, None).await
169        } else if let Some(concurrency) = self.config.partition_concurrency {
170            // Process partitions concurrently using a semaphore to limit parallelism.
171            let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(concurrency.max(1)));
172            let mut handles = Vec::with_capacity(self.config.partitions.len());
173
174            for ctx in &self.config.partitions {
175                let permit =
176                    semaphore.clone().acquire_owned().await.map_err(|e| {
177                        FaucetError::Config(format!("semaphore acquire failed: {e}"))
178                    })?;
179                let fut = self.fetch_partition(Some(ctx), None);
180                handles.push(async move {
181                    let result = fut.await;
182                    drop(permit);
183                    result
184                });
185            }
186
187            let results = futures::future::try_join_all(handles).await?;
188            Ok(results.into_iter().flatten().collect())
189        } else {
190            let mut all_records = Vec::new();
191            for ctx in &self.config.partitions {
192                let records = self.fetch_partition(Some(ctx), None).await?;
193                all_records.extend(records);
194            }
195            Ok(all_records)
196        }
197    }
198
199    /// Fetch all records and deserialize into typed structs.
200    pub async fn fetch_all_as<T: for<'de> Deserialize<'de>>(&self) -> Result<Vec<T>, FaucetError> {
201        let values = self.fetch_all().await?;
202        values
203            .into_iter()
204            .map(|v| serde_json::from_value(v).map_err(FaucetError::Json))
205            .collect()
206    }
207
208    /// Infer a JSON Schema for this stream's records.
209    ///
210    /// If a `schema` is already set on the config, it is returned immediately
211    /// without making any HTTP requests.
212    ///
213    /// Otherwise the stream fetches up to `schema_sample_size` records
214    /// (respecting `max_pages`) and derives a JSON Schema from them.  Fields
215    /// that are absent in some records, or that carry a `null` value, are
216    /// marked as nullable (`["<type>", "null"]`).
217    ///
218    /// Set `schema_sample_size` to `0` to sample all available records.
219    pub async fn infer_schema(&self) -> Result<Value, FaucetError> {
220        if let Some(ref s) = self.config.schema {
221            return Ok(s.clone());
222        }
223        let limit = match self.config.schema_sample_size {
224            0 => None,
225            n => Some(n),
226        };
227        let records = self.fetch_partition(None, limit).await?;
228        Ok(schema::infer_schema(&records))
229    }
230
231    /// Fetch all records in incremental mode, returning the records along with
232    /// the maximum value of `replication_key` observed across those records.
233    ///
234    /// The returned bookmark should be persisted by the caller and passed back
235    /// as `start_replication_value` on the next run.
236    ///
237    /// If no `replication_key` is configured, this behaves identically to
238    /// [`fetch_all`](Self::fetch_all) and the bookmark is `None`.
239    pub async fn fetch_all_incremental(&self) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
240        let records = self.fetch_all().await?;
241        let bookmark = self
242            .config
243            .replication_key
244            .as_deref()
245            .and_then(|key| max_replication_value(&records, key))
246            .cloned();
247        Ok((records, bookmark))
248    }
249
250    /// Stream API pages without buffering the full result set.
251    ///
252    /// This is a thin convenience wrapper around the
253    /// [`Source::stream_pages`](faucet_core::Source::stream_pages) trait
254    /// method — it discards bookmarks and yields one `Vec<Value>` per
255    /// upstream API page. Use the trait method directly if you need
256    /// per-page bookmarks for incremental replication.
257    ///
258    /// Note: partitions are not supported by `stream_pages`. Use `fetch_all`
259    /// for multi-partition streams.
260    ///
261    /// ```rust,no_run
262    /// use faucet_source_rest::{RestStream, RestStreamConfig};
263    /// use futures::StreamExt;
264    ///
265    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
266    /// let stream = RestStream::new(RestStreamConfig::new("https://api.example.com", "/items"))?;
267    /// let mut pages = stream.stream_pages();
268    /// while let Some(page) = pages.next().await {
269    ///     let records = page?;
270    ///     println!("got {} records", records.len());
271    /// }
272    /// # Ok(())
273    /// # }
274    /// ```
275    pub fn stream_pages(
276        &self,
277    ) -> Pin<Box<dyn Stream<Item = Result<Vec<Value>, FaucetError>> + Send + '_>> {
278        let mut inner = self.stream_pages_inner(None);
279        Box::pin(async_stream::try_stream! {
280            loop {
281                let page = std::future::poll_fn(|cx| inner.as_mut().poll_next(cx)).await;
282                match page {
283                    Some(Ok(p)) => yield p.records,
284                    Some(Err(e)) => Err(e)?,
285                    None => break,
286                }
287            }
288        })
289    }
290
291    // ── Private helpers ───────────────────────────────────────────────────────
292
293    /// Core pagination loop shared by [`Source::stream_pages`] and
294    /// [`fetch_partition`](Self::fetch_partition).
295    ///
296    /// Yields one [`faucet_core::StreamPage`] per page. The final page carries
297    /// the consolidated replication bookmark (`Some(value)`); all intermediate
298    /// pages carry `None`. When `context` is `Some`, path placeholders are
299    /// substituted for partition support.
300    fn stream_pages_inner(
301        &self,
302        context: Option<&HashMap<String, Value>>,
303    ) -> Pin<Box<dyn Stream<Item = Result<faucet_core::StreamPage, FaucetError>> + Send + '_>> {
304        // Clone the context into an owned map so it can live inside the
305        // `async_stream` generator without borrowing from the caller.
306        let owned_context: Option<HashMap<String, Value>> = context.cloned();
307
308        Box::pin(async_stream::try_stream! {
309            // Resolve the effective start-bookmark once at the top of the stream.
310            // A runtime override (applied via `Source::apply_start_bookmark` —
311            // typically by the pipeline reading from a `StateStore`) takes
312            // precedence over the static config value.
313            let effective_start: Option<Value> = {
314                let guard = self.runtime_start.lock().await;
315                guard
316                    .clone()
317                    .or_else(|| self.config.start_replication_value.clone())
318            };
319
320            let mut state = PaginationState::default();
321            let mut pages_fetched = 0usize;
322            let mut running_max: Option<Value> = effective_start.clone();
323            let mut bookmark_emitted = false;
324
325            // H13 (audit #146): combining `max_pages` with incremental
326            // replication only makes safe forward progress when the API returns
327            // rows ordered ascending by the replication key. On truncation we
328            // advance the bookmark to the max key seen so far (so the next run
329            // resumes past it — without this the stream would re-read the same
330            // first `max_pages` window forever and never progress); but if the
331            // feed is unordered, unfetched later pages may hold lower keys that
332            // resuming past `running_max` would then drop. Warn loudly so the
333            // requirement is explicit rather than a silent data-loss edge.
334            if self.config.max_pages.is_some()
335                && self.config.replication_method == ReplicationMethod::Incremental
336                && self.config.replication_key.is_some()
337            {
338                tracing::warn!(
339                    "max_pages combined with incremental replication assumes the API returns rows \
340                     ordered ascending by the replication key; an unordered feed can drop unfetched \
341                     lower-key records on resume. Ensure ordering, or remove max_pages for a full \
342                     incremental sweep."
343                );
344            }
345
346            loop {
347                if let Some(max) = self.config.max_pages
348                    && pages_fetched >= max
349                {
350                    tracing::warn!("max pages ({max}) reached");
351                    break;
352                }
353
354                let mut params = self.config.query_params.clone();
355                self.config.pagination.apply_params(&mut params, &state);
356
357                let url_override = match &self.config.pagination {
358                    PaginationStyle::LinkHeader | PaginationStyle::NextLinkInBody { .. } => {
359                        state.next_link.clone()
360                    }
361                    _ => None,
362                };
363
364                let params_clone = params.clone();
365                let ctx_ref = owned_context.as_ref();
366                let is_first_page = pages_fetched == 0;
367                let (body, resp_headers) = retry::execute_with_retry(
368                    // The REST runner takes retries-after-first; the policy holds
369                    // total attempts. Feed both knobs from the resolved policy so
370                    // an injected `resilience:` policy (when legacy fields are
371                    // untouched) governs the retry budget + base backoff while the
372                    // runner keeps its 429 / `Retry-After` handling.
373                    self.retry_policy.max_attempts.saturating_sub(1),
374                    self.retry_policy.base,
375                    || {
376                        self.execute_request(
377                            &params_clone,
378                            url_override.as_deref(),
379                            ctx_ref,
380                            is_first_page,
381                        )
382                    },
383                )
384                .await?;
385
386                let raw_records =
387                    extract::extract_records(&body, self.config.records_path.as_deref())?;
388                let raw_count = raw_records.len();
389
390                let records =
391                    if self.config.replication_method == ReplicationMethod::Incremental {
392                        if let (Some(key), Some(start)) =
393                            (&self.config.replication_key, effective_start.as_ref())
394                        {
395                            filter_incremental(raw_records, key, start)
396                        } else {
397                            raw_records
398                        }
399                    } else {
400                        raw_records
401                    };
402
403                // Track the running max replication value across pages so the
404                // final page can carry the consolidated bookmark.
405                if self.config.replication_method == ReplicationMethod::Incremental
406                    && let Some(key) = self.config.replication_key.as_deref()
407                        && let Some(page_max) = max_replication_value(&records, key) {
408                            let page_max = page_max.clone();
409                            running_max = Some(match running_max.take() {
410                                Some(prev) => max_value(prev, page_max),
411                                None => page_max,
412                            });
413                        }
414
415                // Advance pagination state to learn whether there is a next
416                // page BEFORE yielding the current one. This way the bookmark
417                // is only attached to pages where `has_next == false`, and we
418                // never pre-fetch the next page just to classify the current
419                // one as "final" (which would prevent early exit in callers
420                // such as `fetch_partition` with `max_records`).
421                let has_next = self
422                    .config
423                    .pagination
424                    .advance(&body, &resp_headers, &mut state, raw_count)?;
425                pages_fetched += 1;
426
427                if has_next {
428                    // Intermediate page — yield without bookmark so the
429                    // pipeline does not persist a partial checkpoint.
430                    yield faucet_core::StreamPage { records, bookmark: None };
431                } else if state.current_page_is_duplicate {
432                    // The content-stagnation guard flagged this page as a
433                    // duplicate of the previous one — DROP it (do not emit the
434                    // repeated records to the sink) and stop. The trailing
435                    // bookmark checkpoint below still fires (#321 L1).
436                    break;
437                } else {
438                    // Final page — attach the consolidated bookmark.
439                    bookmark_emitted = running_max.is_some();
440                    yield faucet_core::StreamPage {
441                        records,
442                        bookmark: running_max.clone(),
443                    };
444                    break;
445                }
446
447                if let Some(delay) = self.config.request_delay {
448                    tokio::time::sleep(delay).await;
449                }
450            }
451
452            // Trailing checkpoint: if the loop exited without carrying the
453            // bookmark on a real page (e.g. via max_pages truncation, or with
454            // zero pages fetched and a seeded start bookmark), emit one empty
455            // page carrying the consolidated bookmark so the pipeline still
456            // persists incremental progress and the next run resumes from here.
457            // (Safe forward progress under max_pages assumes ascending order by
458            // the replication key — see the warning emitted above, audit #146 H13.)
459            if !bookmark_emitted && running_max.is_some() {
460                yield faucet_core::StreamPage {
461                    records: Vec::new(),
462                    bookmark: running_max,
463                };
464            }
465        })
466    }
467
468    /// Run the full pagination loop for a single partition context.
469    ///
470    /// `max_records`: when `Some(n)`, stop collecting after `n` records
471    /// (used for schema sampling).
472    async fn fetch_partition(
473        &self,
474        context: Option<&HashMap<String, Value>>,
475        max_records: Option<usize>,
476    ) -> Result<Vec<Value>, FaucetError> {
477        let mut all_records = Vec::new();
478        let mut pages_fetched = 0usize;
479        let mut pages = self.stream_pages_inner(context);
480
481        // Poll the stream without requiring StreamExt (avoids extra dependency).
482        loop {
483            let page = std::future::poll_fn(|cx: &mut std::task::Context<'_>| {
484                pages.as_mut().poll_next(cx)
485            })
486            .await;
487
488            match page {
489                Some(Ok(page)) => {
490                    pages_fetched += 1;
491                    let records = page.records;
492                    match max_records {
493                        Some(limit) => {
494                            let remaining = limit.saturating_sub(all_records.len());
495                            all_records.extend(records.into_iter().take(remaining));
496                            if all_records.len() >= limit {
497                                break;
498                            }
499                        }
500                        None => all_records.extend(records),
501                    }
502                }
503                Some(Err(e)) => return Err(e),
504                None => break,
505            }
506        }
507
508        tracing::info!(
509            stream = self.config.name.as_deref().unwrap_or("(unnamed)"),
510            records = all_records.len(),
511            pages = pages_fetched,
512            "fetch complete"
513        );
514        Ok(all_records)
515    }
516
517    /// Execute a request, transparently refreshing an inline OAuth2 /
518    /// TokenEndpoint token once on a 401.
519    ///
520    /// The cached token's validity is tracked purely by the server-reported
521    /// `expires_in` (and a token with no `expires_in` is cached as valid
522    /// forever), so a *server-side* expiry surfaces only as a 401 on a real
523    /// request. The documented contract is "valid until a 401 forces a
524    /// refresh" — so on a 401 with an inline cached token we invalidate the
525    /// cache and retry exactly once with a freshly-fetched token (F57). Shared
526    /// auth providers manage their own refresh and are not retried here.
527    async fn execute_request(
528        &self,
529        params: &HashMap<String, String>,
530        url_override: Option<&str>,
531        path_context: Option<&HashMap<String, Value>>,
532        is_first_page: bool,
533    ) -> Result<(Value, HeaderMap), FaucetError> {
534        match self
535            .execute_request_once(params, url_override, path_context, is_first_page)
536            .await
537        {
538            Err(FaucetError::HttpStatus { status: 401, .. }) if self.uses_inline_cached_token() => {
539                tracing::warn!(
540                    "401 Unauthorized with a cached inline OAuth2/TokenEndpoint token; \
541                     invalidating the token cache and retrying once with a fresh token"
542                );
543                self.invalidate_inline_token_cache().await;
544                self.execute_request_once(params, url_override, path_context, is_first_page)
545                    .await
546            }
547            other => other,
548        }
549    }
550
551    /// `true` when this source resolves its bearer token from one of the inline
552    /// time-cached auth modes (no shared provider) — the only case where a 401
553    /// should trigger a cache invalidation + retry (F57).
554    fn uses_inline_cached_token(&self) -> bool {
555        self.auth_provider.is_none()
556            && matches!(
557                self.config.auth,
558                AuthSpec::Inline(Auth::OAuth2 { .. })
559                    | AuthSpec::Inline(Auth::TokenEndpoint { .. })
560            )
561    }
562
563    /// Invalidate whichever inline token cache backs the current auth mode, so
564    /// the next request fetches a fresh token (F57).
565    async fn invalidate_inline_token_cache(&self) {
566        match &self.config.auth {
567            AuthSpec::Inline(Auth::OAuth2 { .. }) => self.token_cache.invalidate().await,
568            AuthSpec::Inline(Auth::TokenEndpoint { .. }) => {
569                self.token_endpoint_cache.invalidate().await
570            }
571            _ => {}
572        }
573    }
574
575    /// Execute a single HTTP request and return the response body and headers.
576    ///
577    /// - When `url_override` is `Some`, that full URL is used and query params
578    ///   are **not** appended (Link header pagination encodes them in the URL).
579    /// - When `path_context` is `Some`, `{key}` placeholders in `config.path`
580    ///   are substituted with values from the context map (partition support).
581    async fn execute_request_once(
582        &self,
583        params: &HashMap<String, String>,
584        url_override: Option<&str>,
585        path_context: Option<&HashMap<String, Value>>,
586        is_first_page: bool,
587    ) -> Result<(Value, HeaderMap), FaucetError> {
588        let use_override = url_override.is_some();
589        let url = match url_override {
590            Some(u) => u.to_string(),
591            None => {
592                let path = match path_context {
593                    Some(ctx) => faucet_core::util::substitute_context(&self.config.path, ctx),
594                    None => self.config.path.clone(),
595                };
596                format!("{}/{}", self.config.base_url, path.trim_start_matches('/'))
597            }
598        };
599
600        // Resolve credentials to concrete auth headers. A shared auth provider
601        // (from `auth: { ref }` or injected by a library caller) takes
602        // precedence; otherwise inline OAuth2 / TokenEndpoint are resolved to a
603        // Bearer token via the per-source cache (cached until expiry, avoiding a
604        // token fetch on every request).
605        let resolved_auth = if let Some(provider) = &self.auth_provider {
606            credential_to_auth(provider.credential().await?)
607        } else {
608            match &self.config.auth {
609                AuthSpec::Inline(Auth::OAuth2 {
610                    token_url,
611                    client_id,
612                    client_secret,
613                    scopes,
614                    expiry_ratio,
615                }) => {
616                    let token = self
617                        .token_cache
618                        .get_or_refresh(
619                            &self.client,
620                            token_url,
621                            client_id,
622                            client_secret,
623                            scopes,
624                            *expiry_ratio,
625                        )
626                        .await?;
627                    Auth::Bearer { token }
628                }
629                AuthSpec::Inline(Auth::TokenEndpoint {
630                    url: token_url,
631                    method: token_method,
632                    headers: token_headers,
633                    body: token_body,
634                    token_path,
635                    expiry_path,
636                    expiry_ratio,
637                    response_validator,
638                }) => {
639                    let token = self
640                        .token_endpoint_cache
641                        .get_or_refresh(
642                            &self.client,
643                            token_url,
644                            token_method,
645                            token_headers,
646                            token_body.as_ref(),
647                            token_path,
648                            expiry_path.as_deref(),
649                            *expiry_ratio,
650                            response_validator.as_ref(),
651                        )
652                        .await?;
653                    Auth::Bearer { token }
654                }
655                AuthSpec::Inline(other) => other.clone(),
656                AuthSpec::Reference(r) => {
657                    return Err(FaucetError::Auth(format!(
658                        "auth references provider '{}' but no provider was supplied; \
659                         set one via the CLI `auth:` catalog or `with_auth_provider`",
660                        r.name
661                    )));
662                }
663            }
664        };
665
666        let mut headers = self.config.headers.clone();
667        resolved_auth.apply(&mut headers)?;
668
669        let mut req = self
670            .client
671            .request(self.config.method.clone(), &url)
672            .headers(headers);
673
674        if !use_override {
675            // When parent context is available, substitute {placeholders} in
676            // query param values so child sources can be parameterised.
677            if let Some(ctx) = path_context {
678                let substituted: HashMap<String, String> = params
679                    .iter()
680                    .map(|(k, v)| (k.clone(), faucet_core::util::substitute_context(v, ctx)))
681                    .collect();
682                req = req.query(&substituted.iter().collect::<Vec<_>>());
683            } else {
684                req = req.query(params);
685            }
686        }
687
688        // ApiKeyQuery: inject the API key as a query parameter.
689        if let AuthSpec::Inline(Auth::ApiKeyQuery { param, value }) = &self.config.auth {
690            req = req.query(&[(param.as_str(), value.as_str())]);
691        }
692
693        if let Some(body) = &self.config.body {
694            // Substitute context into body string values when available. Use the
695            // JSON-safe variant: `substitute_context` does NOT escape the value,
696            // so a context value carrying a JSON metacharacter (`"`, `\`, newline)
697            // corrupts the serialized body — the old `unwrap_or(Value::String(..))`
698            // fallback then silently coerced the whole object into a bare string
699            // and POSTed garbage (audit #321 H7). `substitute_context_json`
700            // JSON-escapes string values; an un-parseable result is now a hard
701            // error rather than a silently-wrong payload.
702            if let Some(ctx) = path_context {
703                let body_str = body.to_string();
704                let substituted = faucet_core::util::substitute_context_json(&body_str, ctx);
705                let substituted_value: Value = serde_json::from_str(&substituted).map_err(|e| {
706                    FaucetError::Source(format!(
707                        "REST source: context substitution produced an invalid JSON body: {e}"
708                    ))
709                })?;
710                req = req.json(&substituted_value);
711            } else {
712                req = req.json(body);
713            }
714        }
715
716        let resp = req.send().await?;
717        let status = resp.status();
718
719        // 429 Too Many Requests: honour Retry-After before retrying.
720        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
721            let wait = parse_retry_after(resp.headers());
722            return Err(FaucetError::RateLimited(wait));
723        }
724
725        // Tolerated errors: treat as an empty page ONLY on the first request,
726        // where they legitimately mean "this resource is absent/empty". Mid-
727        // pagination, an empty page makes every pagination style read "last
728        // page" and stop, silently dropping every remaining page as a
729        // "successful" run (#78/#7). There we fall through to the real error
730        // path: the retry executor retries 5xx, and a persistent error fails
731        // loudly instead of truncating the stream.
732        if is_first_page && self.config.tolerated_http_errors.contains(&status.as_u16()) {
733            tracing::debug!(
734                status = status.as_u16(),
735                "tolerated HTTP error on first request; treating as empty page"
736            );
737            return Ok((Value::Array(vec![]), HeaderMap::new()));
738        }
739        if !is_first_page && self.config.tolerated_http_errors.contains(&status.as_u16()) {
740            tracing::warn!(
741                status = status.as_u16(),
742                "tolerated HTTP error mid-pagination; surfacing as an error to avoid \
743                 silently truncating the stream"
744            );
745        }
746
747        // For non-success responses, capture the body for debugging before
748        // returning the error. This gives callers (and logs) the server's
749        // error message rather than just a status code.
750        if !status.is_success() {
751            // Redact any auth secret carried in the query string before it lands
752            // in the error (which renders the URL in `Display` → logs). The
753            // `api_key_query` value is user-configured, so it is not marked
754            // sensitive like a Bearer/Basic header and would otherwise leak on
755            // any 4xx/5xx (audit #321 L2).
756            let resp_url = redact_error_url(resp.url(), &self.config.auth);
757            let body_text = resp.text().await.unwrap_or_default();
758            // Truncate very long error bodies to avoid bloating logs/errors.
759            let truncated = if body_text.len() > 1024 {
760                // Find a safe UTF-8 boundary at or before 1024 bytes.
761                let end = body_text.floor_char_boundary(1024);
762                format!("{}...(truncated)", &body_text[..end])
763            } else {
764                body_text
765            };
766            return Err(FaucetError::HttpStatus {
767                status: status.as_u16(),
768                url: resp_url,
769                body: truncated,
770            });
771        }
772
773        let resp_headers = resp.headers().clone();
774
775        // A 204 No Content — or any 2xx with an empty / whitespace-only body —
776        // carries no JSON to parse. `resp.json()` on such a response yields a
777        // non-retriable decode error ("EOF while parsing a value") that aborts
778        // the run; treat it as an empty page ("no data") instead (#146 M10). A
779        // non-empty body that isn't valid JSON still surfaces as a parse error.
780        if status == reqwest::StatusCode::NO_CONTENT {
781            return Ok((Value::Array(vec![]), resp_headers));
782        }
783        let bytes = resp.bytes().await?;
784        if bytes.iter().all(u8::is_ascii_whitespace) {
785            return Ok((Value::Array(vec![]), resp_headers));
786        }
787        let body: Value = serde_json::from_slice(&bytes)?;
788        Ok((body, resp_headers))
789    }
790}
791
792/// Render a response URL for an error message with any auth secret in the query
793/// string redacted (audit #321 L2). Redacts the user-configured
794/// `api_key_query` parameter by name (which `redact_uri_credentials` cannot
795/// know), then applies the shared credential/query-secret redaction for the
796/// common key names and any URL userinfo.
797fn redact_error_url(url: &reqwest::Url, auth: &AuthSpec<Auth>) -> String {
798    let mut redacted = url.clone();
799    if let AuthSpec::Inline(Auth::ApiKeyQuery { param, .. }) = auth {
800        let pairs: Vec<(String, String)> = url
801            .query_pairs()
802            .map(|(k, v)| {
803                if k == param.as_str() {
804                    (k.into_owned(), "***".to_string())
805                } else {
806                    (k.into_owned(), v.into_owned())
807                }
808            })
809            .collect();
810        redacted.set_query(None);
811        if !pairs.is_empty() {
812            let mut qp = redacted.query_pairs_mut();
813            for (k, v) in &pairs {
814                qp.append_pair(k, v);
815            }
816        }
817    }
818    faucet_core::redact_uri_credentials(redacted.as_str())
819}
820
821/// Parse the `Retry-After` header. RFC 7231 permits **either** delta-seconds
822/// **or** an HTTP-date; we honour both. An HTTP-date in the past yields a zero
823/// wait (retry now). Falls back to 60 s only when the header is absent or in
824/// neither form.
825fn parse_retry_after(headers: &HeaderMap) -> Duration {
826    const DEFAULT: Duration = Duration::from_secs(60);
827    let Some(raw) = headers
828        .get(reqwest::header::RETRY_AFTER)
829        .and_then(|v| v.to_str().ok())
830        .map(str::trim)
831    else {
832        return DEFAULT;
833    };
834    // delta-seconds form.
835    if let Ok(secs) = raw.parse::<u64>() {
836        return Duration::from_secs(secs);
837    }
838    // HTTP-date form (IMF-fixdate / RFC 850 / asctime).
839    if let Ok(when) = httpdate::parse_http_date(raw) {
840        return when
841            .duration_since(std::time::SystemTime::now())
842            .unwrap_or(Duration::ZERO);
843    }
844    DEFAULT
845}
846
847#[async_trait]
848impl faucet_core::Source for RestStream {
849    async fn fetch_with_context(
850        &self,
851        context: &std::collections::HashMap<String, serde_json::Value>,
852    ) -> Result<Vec<Value>, FaucetError> {
853        if context.is_empty() {
854            // No parent context — use normal fetch_all with partitions
855            RestStream::fetch_all(self).await
856        } else if self.config.partitions.is_empty() {
857            // Parent context, no partitions — use context directly as partition context
858            self.fetch_partition(Some(context), None).await
859        } else {
860            // Both parent context and partitions — merge context into each partition
861            let mut all_records = Vec::new();
862            for partition in &self.config.partitions {
863                let mut merged = context.clone();
864                merged.extend(partition.iter().map(|(k, v)| (k.clone(), v.clone())));
865                all_records.extend(self.fetch_partition(Some(&merged), None).await?);
866            }
867            Ok(all_records)
868        }
869    }
870
871    async fn fetch_with_context_incremental(
872        &self,
873        context: &std::collections::HashMap<String, serde_json::Value>,
874    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
875        let records = self.fetch_with_context(context).await?;
876        let bookmark = self
877            .config
878            .replication_key
879            .as_deref()
880            .and_then(|key| faucet_core::replication::max_replication_value(&records, key))
881            .cloned();
882        Ok((records, bookmark))
883    }
884
885    fn connector_name(&self) -> &'static str {
886        "rest"
887    }
888
889    fn config_schema(&self) -> serde_json::Value {
890        serde_json::to_value(faucet_core::schema_for!(RestStreamConfig))
891            .expect("schema serialization")
892    }
893
894    fn dataset_uri(&self) -> String {
895        format!(
896            "{}{}",
897            faucet_core::redact_uri_credentials(&self.config.base_url),
898            self.config.path
899        )
900    }
901
902    fn state_key(&self) -> Option<String> {
903        self.config.state_key.clone()
904    }
905
906    fn stream_pages<'a>(
907        &'a self,
908        context: &'a HashMap<String, Value>,
909        _batch_size: usize,
910    ) -> Pin<Box<dyn Stream<Item = Result<faucet_core::StreamPage, FaucetError>> + Send + 'a>> {
911        // RestStream chunks by upstream-API page boundaries, not by an
912        // in-memory `batch_size` knob. The arg is accepted for trait
913        // conformance and reserved for a future `page_size` mapping.
914        self.stream_pages_inner(Some(context))
915    }
916
917    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
918        *self.runtime_start.lock().await = Some(bookmark);
919        Ok(())
920    }
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926    use serde_json::json;
927
928    #[test]
929    fn injected_policy_applies_when_legacy_fields_at_defaults() {
930        // Config left at the default max_retries/retry_backoff → injection wins.
931        let stream =
932            RestStream::new(RestStreamConfig::new("https://api.example.com", "/items")).unwrap();
933        let injected = faucet_core::RetryPolicy {
934            max_attempts: 9,
935            base: Duration::from_secs(7),
936            ..faucet_core::RetryPolicy::default()
937        };
938        let stream = stream.with_retry_policy(injected);
939        assert_eq!(stream.retry_policy.max_attempts, 9);
940        assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
941    }
942
943    #[test]
944    fn legacy_fields_take_precedence_over_injected_policy() {
945        // User set max_retries explicitly → the injected policy is ignored and
946        // the connector's own legacy fields keep governing retries.
947        let config = RestStreamConfig::new("https://api.example.com", "/items").max_retries(7);
948        let stream = RestStream::new(config).unwrap();
949        // Default policy derived from legacy fields: max_attempts = 7 + 1.
950        assert_eq!(stream.retry_policy.max_attempts, 8);
951        let injected = faucet_core::RetryPolicy {
952            max_attempts: 99,
953            base: Duration::from_secs(42),
954            ..faucet_core::RetryPolicy::default()
955        };
956        let stream = stream.with_retry_policy(injected);
957        // Unchanged: the legacy max_retries(7) still wins.
958        assert_eq!(stream.retry_policy.max_attempts, 8);
959        assert_eq!(stream.retry_policy.base, DEFAULT_RETRY_BACKOFF);
960    }
961
962    #[test]
963    fn redact_error_url_hides_api_key_query_param() {
964        // #321 L2: a custom `api_key_query` param name is redacted by name.
965        let auth = AuthSpec::Inline(Auth::ApiKeyQuery {
966            param: "api_token".into(),
967            value: "SUPERSECRET".into(),
968        });
969        let url =
970            reqwest::Url::parse("https://api.example.com/v1/items?page=2&api_token=SUPERSECRET")
971                .unwrap();
972        let redacted = redact_error_url(&url, &auth);
973        assert!(
974            !redacted.contains("SUPERSECRET"),
975            "secret must be gone: {redacted}"
976        );
977        assert!(redacted.contains("api_token=%2A%2A%2A") || redacted.contains("api_token=***"));
978        assert!(
979            redacted.contains("page=2"),
980            "non-secret param kept: {redacted}"
981        );
982    }
983
984    #[test]
985    fn redact_error_url_without_api_key_query_still_scrubs_common_keys() {
986        // Non-ApiKeyQuery auth: the shared redaction still strips common secret
987        // query keys and userinfo.
988        let auth: AuthSpec<Auth> = AuthSpec::Inline(Auth::None);
989        let url = reqwest::Url::parse("https://u:pw@api.example.com/v1/items?token=abc").unwrap();
990        let redacted = redact_error_url(&url, &auth);
991        assert!(
992            !redacted.contains("abc"),
993            "common secret key redacted: {redacted}"
994        );
995        assert!(!redacted.contains("pw@"), "userinfo redacted: {redacted}");
996    }
997
998    #[test]
999    fn test_substitute_context_substitutes_placeholders() {
1000        let mut ctx = HashMap::new();
1001        ctx.insert("org_id".to_string(), json!("acme"));
1002        ctx.insert("repo".to_string(), json!("myrepo"));
1003        let result =
1004            faucet_core::util::substitute_context("/orgs/{org_id}/repos/{repo}/issues", &ctx);
1005        assert_eq!(result, "/orgs/acme/repos/myrepo/issues");
1006    }
1007
1008    #[test]
1009    fn test_substitute_context_no_placeholders() {
1010        let ctx = HashMap::new();
1011        let result = faucet_core::util::substitute_context("/api/users", &ctx);
1012        assert_eq!(result, "/api/users");
1013    }
1014
1015    #[test]
1016    fn test_substitute_context_numeric_value() {
1017        let mut ctx = HashMap::new();
1018        ctx.insert("id".to_string(), json!(42));
1019        let result = faucet_core::util::substitute_context("/items/{id}", &ctx);
1020        assert_eq!(result, "/items/42");
1021    }
1022
1023    #[test]
1024    fn test_parse_retry_after_valid() {
1025        let mut headers = HeaderMap::new();
1026        headers.insert(
1027            reqwest::header::RETRY_AFTER,
1028            reqwest::header::HeaderValue::from_static("30"),
1029        );
1030        assert_eq!(parse_retry_after(&headers), Duration::from_secs(30));
1031    }
1032
1033    #[test]
1034    fn test_parse_retry_after_missing_defaults_to_60() {
1035        assert_eq!(
1036            parse_retry_after(&HeaderMap::new()),
1037            Duration::from_secs(60)
1038        );
1039    }
1040
1041    #[test]
1042    fn test_parse_retry_after_non_numeric_defaults_to_60() {
1043        let mut headers = HeaderMap::new();
1044        headers.insert(
1045            reqwest::header::RETRY_AFTER,
1046            reqwest::header::HeaderValue::from_static("not-a-number"),
1047        );
1048        assert_eq!(parse_retry_after(&headers), Duration::from_secs(60));
1049    }
1050
1051    #[test]
1052    fn test_parse_retry_after_http_date() {
1053        // RFC 7231 permits an HTTP-date form instead of delta-seconds.
1054        let future = std::time::SystemTime::now() + Duration::from_secs(7200);
1055        let date = httpdate::fmt_http_date(future);
1056        let mut headers = HeaderMap::new();
1057        headers.insert(
1058            reqwest::header::RETRY_AFTER,
1059            reqwest::header::HeaderValue::from_str(&date).unwrap(),
1060        );
1061        let d = parse_retry_after(&headers);
1062        // ~2 hours out — must not collapse to the 60s fallback.
1063        assert!(
1064            d > Duration::from_secs(3600),
1065            "expected ~2h from HTTP-date, got {d:?}"
1066        );
1067        assert!(
1068            d <= Duration::from_secs(7200),
1069            "should not exceed the target instant, got {d:?}"
1070        );
1071    }
1072
1073    #[test]
1074    fn test_parse_retry_after_past_http_date_is_zero() {
1075        // A date already in the past → retry now (zero wait), not the fallback.
1076        let past = std::time::SystemTime::now() - Duration::from_secs(3600);
1077        let date = httpdate::fmt_http_date(past);
1078        let mut headers = HeaderMap::new();
1079        headers.insert(
1080            reqwest::header::RETRY_AFTER,
1081            reqwest::header::HeaderValue::from_str(&date).unwrap(),
1082        );
1083        assert_eq!(parse_retry_after(&headers), Duration::ZERO);
1084    }
1085
1086    #[test]
1087    fn test_new_rejects_invalid_expiry_ratio_zero() {
1088        let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
1089            token_url: "https://auth.example.com/token".into(),
1090            client_id: "id".into(),
1091            client_secret: "secret".into(),
1092            scopes: vec![],
1093            expiry_ratio: 0.0,
1094        });
1095        let result = RestStream::new(config);
1096        assert!(result.is_err());
1097        assert!(matches!(result, Err(FaucetError::Auth(_))));
1098    }
1099
1100    #[test]
1101    fn test_new_rejects_invalid_expiry_ratio_negative() {
1102        let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
1103            token_url: "https://auth.example.com/token".into(),
1104            client_id: "id".into(),
1105            client_secret: "secret".into(),
1106            scopes: vec![],
1107            expiry_ratio: -0.5,
1108        });
1109        assert!(RestStream::new(config).is_err());
1110    }
1111
1112    #[test]
1113    fn test_new_rejects_invalid_expiry_ratio_above_one() {
1114        let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
1115            token_url: "https://auth.example.com/token".into(),
1116            client_id: "id".into(),
1117            client_secret: "secret".into(),
1118            scopes: vec![],
1119            expiry_ratio: 1.5,
1120        });
1121        assert!(RestStream::new(config).is_err());
1122    }
1123
1124    #[test]
1125    fn test_new_accepts_valid_expiry_ratio() {
1126        let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
1127            token_url: "https://auth.example.com/token".into(),
1128            client_id: "id".into(),
1129            client_secret: "secret".into(),
1130            scopes: vec![],
1131            expiry_ratio: 1.0,
1132        });
1133        assert!(RestStream::new(config).is_ok());
1134    }
1135
1136    #[test]
1137    fn test_new_with_no_auth_succeeds() {
1138        let config = RestStreamConfig::new("https://example.com", "/data");
1139        assert!(RestStream::new(config).is_ok());
1140    }
1141
1142    #[test]
1143    fn test_new_with_timeout() {
1144        let config =
1145            RestStreamConfig::new("https://example.com", "/data").timeout(Duration::from_secs(10));
1146        assert!(RestStream::new(config).is_ok());
1147    }
1148
1149    #[test]
1150    fn test_substitute_context_missing_placeholder_unchanged() {
1151        let mut ctx = HashMap::new();
1152        ctx.insert("org".to_string(), json!("acme"));
1153        let result = faucet_core::util::substitute_context("/items/{missing}", &ctx);
1154        assert_eq!(result, "/items/{missing}");
1155    }
1156
1157    #[test]
1158    fn test_substitute_context_boolean_value() {
1159        let mut ctx = HashMap::new();
1160        ctx.insert("flag".to_string(), json!(true));
1161        let result = faucet_core::util::substitute_context("/items/{flag}", &ctx);
1162        assert_eq!(result, "/items/true");
1163    }
1164
1165    #[test]
1166    fn rest_source_connector_name_is_rest() {
1167        use faucet_core::Source;
1168        let source = RestStream::new(RestStreamConfig::new("https://example.com", "/data"))
1169            .expect("minimal RestStream construction");
1170        assert_eq!(source.connector_name(), "rest");
1171    }
1172
1173    #[test]
1174    fn dataset_uri_combines_base_and_path() {
1175        use faucet_core::Source;
1176        let source = RestStream::new(RestStreamConfig::new(
1177            "https://api.example.com",
1178            "/v1/users",
1179        ))
1180        .unwrap();
1181        assert_eq!(source.dataset_uri(), "https://api.example.com/v1/users");
1182    }
1183
1184    #[test]
1185    fn dataset_uri_redacts_credentials() {
1186        use faucet_core::Source;
1187        let source = RestStream::new(RestStreamConfig::new(
1188            "https://user:secret@api.example.com",
1189            "/v1/data",
1190        ))
1191        .unwrap();
1192        assert_eq!(source.dataset_uri(), "https://api.example.com/v1/data");
1193    }
1194}