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 {
432                    // Final page — attach the consolidated bookmark.
433                    bookmark_emitted = running_max.is_some();
434                    yield faucet_core::StreamPage {
435                        records,
436                        bookmark: running_max.clone(),
437                    };
438                    break;
439                }
440
441                if let Some(delay) = self.config.request_delay {
442                    tokio::time::sleep(delay).await;
443                }
444            }
445
446            // Trailing checkpoint: if the loop exited without carrying the
447            // bookmark on a real page (e.g. via max_pages truncation, or with
448            // zero pages fetched and a seeded start bookmark), emit one empty
449            // page carrying the consolidated bookmark so the pipeline still
450            // persists incremental progress and the next run resumes from here.
451            // (Safe forward progress under max_pages assumes ascending order by
452            // the replication key — see the warning emitted above, audit #146 H13.)
453            if !bookmark_emitted && running_max.is_some() {
454                yield faucet_core::StreamPage {
455                    records: Vec::new(),
456                    bookmark: running_max,
457                };
458            }
459        })
460    }
461
462    /// Run the full pagination loop for a single partition context.
463    ///
464    /// `max_records`: when `Some(n)`, stop collecting after `n` records
465    /// (used for schema sampling).
466    async fn fetch_partition(
467        &self,
468        context: Option<&HashMap<String, Value>>,
469        max_records: Option<usize>,
470    ) -> Result<Vec<Value>, FaucetError> {
471        let mut all_records = Vec::new();
472        let mut pages_fetched = 0usize;
473        let mut pages = self.stream_pages_inner(context);
474
475        // Poll the stream without requiring StreamExt (avoids extra dependency).
476        loop {
477            let page = std::future::poll_fn(|cx: &mut std::task::Context<'_>| {
478                pages.as_mut().poll_next(cx)
479            })
480            .await;
481
482            match page {
483                Some(Ok(page)) => {
484                    pages_fetched += 1;
485                    let records = page.records;
486                    match max_records {
487                        Some(limit) => {
488                            let remaining = limit.saturating_sub(all_records.len());
489                            all_records.extend(records.into_iter().take(remaining));
490                            if all_records.len() >= limit {
491                                break;
492                            }
493                        }
494                        None => all_records.extend(records),
495                    }
496                }
497                Some(Err(e)) => return Err(e),
498                None => break,
499            }
500        }
501
502        tracing::info!(
503            stream = self.config.name.as_deref().unwrap_or("(unnamed)"),
504            records = all_records.len(),
505            pages = pages_fetched,
506            "fetch complete"
507        );
508        Ok(all_records)
509    }
510
511    /// Execute a request, transparently refreshing an inline OAuth2 /
512    /// TokenEndpoint token once on a 401.
513    ///
514    /// The cached token's validity is tracked purely by the server-reported
515    /// `expires_in` (and a token with no `expires_in` is cached as valid
516    /// forever), so a *server-side* expiry surfaces only as a 401 on a real
517    /// request. The documented contract is "valid until a 401 forces a
518    /// refresh" — so on a 401 with an inline cached token we invalidate the
519    /// cache and retry exactly once with a freshly-fetched token (F57). Shared
520    /// auth providers manage their own refresh and are not retried here.
521    async fn execute_request(
522        &self,
523        params: &HashMap<String, String>,
524        url_override: Option<&str>,
525        path_context: Option<&HashMap<String, Value>>,
526        is_first_page: bool,
527    ) -> Result<(Value, HeaderMap), FaucetError> {
528        match self
529            .execute_request_once(params, url_override, path_context, is_first_page)
530            .await
531        {
532            Err(FaucetError::HttpStatus { status: 401, .. }) if self.uses_inline_cached_token() => {
533                tracing::warn!(
534                    "401 Unauthorized with a cached inline OAuth2/TokenEndpoint token; \
535                     invalidating the token cache and retrying once with a fresh token"
536                );
537                self.invalidate_inline_token_cache().await;
538                self.execute_request_once(params, url_override, path_context, is_first_page)
539                    .await
540            }
541            other => other,
542        }
543    }
544
545    /// `true` when this source resolves its bearer token from one of the inline
546    /// time-cached auth modes (no shared provider) — the only case where a 401
547    /// should trigger a cache invalidation + retry (F57).
548    fn uses_inline_cached_token(&self) -> bool {
549        self.auth_provider.is_none()
550            && matches!(
551                self.config.auth,
552                AuthSpec::Inline(Auth::OAuth2 { .. })
553                    | AuthSpec::Inline(Auth::TokenEndpoint { .. })
554            )
555    }
556
557    /// Invalidate whichever inline token cache backs the current auth mode, so
558    /// the next request fetches a fresh token (F57).
559    async fn invalidate_inline_token_cache(&self) {
560        match &self.config.auth {
561            AuthSpec::Inline(Auth::OAuth2 { .. }) => self.token_cache.invalidate().await,
562            AuthSpec::Inline(Auth::TokenEndpoint { .. }) => {
563                self.token_endpoint_cache.invalidate().await
564            }
565            _ => {}
566        }
567    }
568
569    /// Execute a single HTTP request and return the response body and headers.
570    ///
571    /// - When `url_override` is `Some`, that full URL is used and query params
572    ///   are **not** appended (Link header pagination encodes them in the URL).
573    /// - When `path_context` is `Some`, `{key}` placeholders in `config.path`
574    ///   are substituted with values from the context map (partition support).
575    async fn execute_request_once(
576        &self,
577        params: &HashMap<String, String>,
578        url_override: Option<&str>,
579        path_context: Option<&HashMap<String, Value>>,
580        is_first_page: bool,
581    ) -> Result<(Value, HeaderMap), FaucetError> {
582        let use_override = url_override.is_some();
583        let url = match url_override {
584            Some(u) => u.to_string(),
585            None => {
586                let path = match path_context {
587                    Some(ctx) => faucet_core::util::substitute_context(&self.config.path, ctx),
588                    None => self.config.path.clone(),
589                };
590                format!("{}/{}", self.config.base_url, path.trim_start_matches('/'))
591            }
592        };
593
594        // Resolve credentials to concrete auth headers. A shared auth provider
595        // (from `auth: { ref }` or injected by a library caller) takes
596        // precedence; otherwise inline OAuth2 / TokenEndpoint are resolved to a
597        // Bearer token via the per-source cache (cached until expiry, avoiding a
598        // token fetch on every request).
599        let resolved_auth = if let Some(provider) = &self.auth_provider {
600            credential_to_auth(provider.credential().await?)
601        } else {
602            match &self.config.auth {
603                AuthSpec::Inline(Auth::OAuth2 {
604                    token_url,
605                    client_id,
606                    client_secret,
607                    scopes,
608                    expiry_ratio,
609                }) => {
610                    let token = self
611                        .token_cache
612                        .get_or_refresh(
613                            &self.client,
614                            token_url,
615                            client_id,
616                            client_secret,
617                            scopes,
618                            *expiry_ratio,
619                        )
620                        .await?;
621                    Auth::Bearer { token }
622                }
623                AuthSpec::Inline(Auth::TokenEndpoint {
624                    url: token_url,
625                    method: token_method,
626                    headers: token_headers,
627                    body: token_body,
628                    token_path,
629                    expiry_path,
630                    expiry_ratio,
631                    response_validator,
632                }) => {
633                    let token = self
634                        .token_endpoint_cache
635                        .get_or_refresh(
636                            &self.client,
637                            token_url,
638                            token_method,
639                            token_headers,
640                            token_body.as_ref(),
641                            token_path,
642                            expiry_path.as_deref(),
643                            *expiry_ratio,
644                            response_validator.as_ref(),
645                        )
646                        .await?;
647                    Auth::Bearer { token }
648                }
649                AuthSpec::Inline(other) => other.clone(),
650                AuthSpec::Reference(r) => {
651                    return Err(FaucetError::Auth(format!(
652                        "auth references provider '{}' but no provider was supplied; \
653                         set one via the CLI `auth:` catalog or `with_auth_provider`",
654                        r.name
655                    )));
656                }
657            }
658        };
659
660        let mut headers = self.config.headers.clone();
661        resolved_auth.apply(&mut headers)?;
662
663        let mut req = self
664            .client
665            .request(self.config.method.clone(), &url)
666            .headers(headers);
667
668        if !use_override {
669            // When parent context is available, substitute {placeholders} in
670            // query param values so child sources can be parameterised.
671            if let Some(ctx) = path_context {
672                let substituted: HashMap<String, String> = params
673                    .iter()
674                    .map(|(k, v)| (k.clone(), faucet_core::util::substitute_context(v, ctx)))
675                    .collect();
676                req = req.query(&substituted.iter().collect::<Vec<_>>());
677            } else {
678                req = req.query(params);
679            }
680        }
681
682        // ApiKeyQuery: inject the API key as a query parameter.
683        if let AuthSpec::Inline(Auth::ApiKeyQuery { param, value }) = &self.config.auth {
684            req = req.query(&[(param.as_str(), value.as_str())]);
685        }
686
687        if let Some(body) = &self.config.body {
688            // Substitute context into body string values when available.
689            if let Some(ctx) = path_context {
690                let body_str = body.to_string();
691                let substituted = faucet_core::util::substitute_context(&body_str, ctx);
692                let substituted_value: Value =
693                    serde_json::from_str(&substituted).unwrap_or(Value::String(substituted));
694                req = req.json(&substituted_value);
695            } else {
696                req = req.json(body);
697            }
698        }
699
700        let resp = req.send().await?;
701        let status = resp.status();
702
703        // 429 Too Many Requests: honour Retry-After before retrying.
704        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
705            let wait = parse_retry_after(resp.headers());
706            return Err(FaucetError::RateLimited(wait));
707        }
708
709        // Tolerated errors: treat as an empty page ONLY on the first request,
710        // where they legitimately mean "this resource is absent/empty". Mid-
711        // pagination, an empty page makes every pagination style read "last
712        // page" and stop, silently dropping every remaining page as a
713        // "successful" run (#78/#7). There we fall through to the real error
714        // path: the retry executor retries 5xx, and a persistent error fails
715        // loudly instead of truncating the stream.
716        if is_first_page && self.config.tolerated_http_errors.contains(&status.as_u16()) {
717            tracing::debug!(
718                status = status.as_u16(),
719                "tolerated HTTP error on first request; treating as empty page"
720            );
721            return Ok((Value::Array(vec![]), HeaderMap::new()));
722        }
723        if !is_first_page && self.config.tolerated_http_errors.contains(&status.as_u16()) {
724            tracing::warn!(
725                status = status.as_u16(),
726                "tolerated HTTP error mid-pagination; surfacing as an error to avoid \
727                 silently truncating the stream"
728            );
729        }
730
731        // For non-success responses, capture the body for debugging before
732        // returning the error. This gives callers (and logs) the server's
733        // error message rather than just a status code.
734        if !status.is_success() {
735            let resp_url = resp.url().to_string();
736            let body_text = resp.text().await.unwrap_or_default();
737            // Truncate very long error bodies to avoid bloating logs/errors.
738            let truncated = if body_text.len() > 1024 {
739                // Find a safe UTF-8 boundary at or before 1024 bytes.
740                let end = body_text.floor_char_boundary(1024);
741                format!("{}...(truncated)", &body_text[..end])
742            } else {
743                body_text
744            };
745            return Err(FaucetError::HttpStatus {
746                status: status.as_u16(),
747                url: resp_url,
748                body: truncated,
749            });
750        }
751
752        let resp_headers = resp.headers().clone();
753
754        // A 204 No Content — or any 2xx with an empty / whitespace-only body —
755        // carries no JSON to parse. `resp.json()` on such a response yields a
756        // non-retriable decode error ("EOF while parsing a value") that aborts
757        // the run; treat it as an empty page ("no data") instead (#146 M10). A
758        // non-empty body that isn't valid JSON still surfaces as a parse error.
759        if status == reqwest::StatusCode::NO_CONTENT {
760            return Ok((Value::Array(vec![]), resp_headers));
761        }
762        let bytes = resp.bytes().await?;
763        if bytes.iter().all(u8::is_ascii_whitespace) {
764            return Ok((Value::Array(vec![]), resp_headers));
765        }
766        let body: Value = serde_json::from_slice(&bytes)?;
767        Ok((body, resp_headers))
768    }
769}
770
771/// Parse the `Retry-After` header. RFC 7231 permits **either** delta-seconds
772/// **or** an HTTP-date; we honour both. An HTTP-date in the past yields a zero
773/// wait (retry now). Falls back to 60 s only when the header is absent or in
774/// neither form.
775fn parse_retry_after(headers: &HeaderMap) -> Duration {
776    const DEFAULT: Duration = Duration::from_secs(60);
777    let Some(raw) = headers
778        .get(reqwest::header::RETRY_AFTER)
779        .and_then(|v| v.to_str().ok())
780        .map(str::trim)
781    else {
782        return DEFAULT;
783    };
784    // delta-seconds form.
785    if let Ok(secs) = raw.parse::<u64>() {
786        return Duration::from_secs(secs);
787    }
788    // HTTP-date form (IMF-fixdate / RFC 850 / asctime).
789    if let Ok(when) = httpdate::parse_http_date(raw) {
790        return when
791            .duration_since(std::time::SystemTime::now())
792            .unwrap_or(Duration::ZERO);
793    }
794    DEFAULT
795}
796
797#[async_trait]
798impl faucet_core::Source for RestStream {
799    async fn fetch_with_context(
800        &self,
801        context: &std::collections::HashMap<String, serde_json::Value>,
802    ) -> Result<Vec<Value>, FaucetError> {
803        if context.is_empty() {
804            // No parent context — use normal fetch_all with partitions
805            RestStream::fetch_all(self).await
806        } else if self.config.partitions.is_empty() {
807            // Parent context, no partitions — use context directly as partition context
808            self.fetch_partition(Some(context), None).await
809        } else {
810            // Both parent context and partitions — merge context into each partition
811            let mut all_records = Vec::new();
812            for partition in &self.config.partitions {
813                let mut merged = context.clone();
814                merged.extend(partition.iter().map(|(k, v)| (k.clone(), v.clone())));
815                all_records.extend(self.fetch_partition(Some(&merged), None).await?);
816            }
817            Ok(all_records)
818        }
819    }
820
821    async fn fetch_with_context_incremental(
822        &self,
823        context: &std::collections::HashMap<String, serde_json::Value>,
824    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
825        let records = self.fetch_with_context(context).await?;
826        let bookmark = self
827            .config
828            .replication_key
829            .as_deref()
830            .and_then(|key| faucet_core::replication::max_replication_value(&records, key))
831            .cloned();
832        Ok((records, bookmark))
833    }
834
835    fn connector_name(&self) -> &'static str {
836        "rest"
837    }
838
839    fn config_schema(&self) -> serde_json::Value {
840        serde_json::to_value(faucet_core::schema_for!(RestStreamConfig))
841            .expect("schema serialization")
842    }
843
844    fn dataset_uri(&self) -> String {
845        format!(
846            "{}{}",
847            faucet_core::redact_uri_credentials(&self.config.base_url),
848            self.config.path
849        )
850    }
851
852    fn state_key(&self) -> Option<String> {
853        self.config.state_key.clone()
854    }
855
856    fn stream_pages<'a>(
857        &'a self,
858        context: &'a HashMap<String, Value>,
859        _batch_size: usize,
860    ) -> Pin<Box<dyn Stream<Item = Result<faucet_core::StreamPage, FaucetError>> + Send + 'a>> {
861        // RestStream chunks by upstream-API page boundaries, not by an
862        // in-memory `batch_size` knob. The arg is accepted for trait
863        // conformance and reserved for a future `page_size` mapping.
864        self.stream_pages_inner(Some(context))
865    }
866
867    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
868        *self.runtime_start.lock().await = Some(bookmark);
869        Ok(())
870    }
871}
872
873#[cfg(test)]
874mod tests {
875    use super::*;
876    use serde_json::json;
877
878    #[test]
879    fn injected_policy_applies_when_legacy_fields_at_defaults() {
880        // Config left at the default max_retries/retry_backoff → injection wins.
881        let stream =
882            RestStream::new(RestStreamConfig::new("https://api.example.com", "/items")).unwrap();
883        let injected = faucet_core::RetryPolicy {
884            max_attempts: 9,
885            base: Duration::from_secs(7),
886            ..faucet_core::RetryPolicy::default()
887        };
888        let stream = stream.with_retry_policy(injected);
889        assert_eq!(stream.retry_policy.max_attempts, 9);
890        assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
891    }
892
893    #[test]
894    fn legacy_fields_take_precedence_over_injected_policy() {
895        // User set max_retries explicitly → the injected policy is ignored and
896        // the connector's own legacy fields keep governing retries.
897        let config = RestStreamConfig::new("https://api.example.com", "/items").max_retries(7);
898        let stream = RestStream::new(config).unwrap();
899        // Default policy derived from legacy fields: max_attempts = 7 + 1.
900        assert_eq!(stream.retry_policy.max_attempts, 8);
901        let injected = faucet_core::RetryPolicy {
902            max_attempts: 99,
903            base: Duration::from_secs(42),
904            ..faucet_core::RetryPolicy::default()
905        };
906        let stream = stream.with_retry_policy(injected);
907        // Unchanged: the legacy max_retries(7) still wins.
908        assert_eq!(stream.retry_policy.max_attempts, 8);
909        assert_eq!(stream.retry_policy.base, DEFAULT_RETRY_BACKOFF);
910    }
911
912    #[test]
913    fn test_substitute_context_substitutes_placeholders() {
914        let mut ctx = HashMap::new();
915        ctx.insert("org_id".to_string(), json!("acme"));
916        ctx.insert("repo".to_string(), json!("myrepo"));
917        let result =
918            faucet_core::util::substitute_context("/orgs/{org_id}/repos/{repo}/issues", &ctx);
919        assert_eq!(result, "/orgs/acme/repos/myrepo/issues");
920    }
921
922    #[test]
923    fn test_substitute_context_no_placeholders() {
924        let ctx = HashMap::new();
925        let result = faucet_core::util::substitute_context("/api/users", &ctx);
926        assert_eq!(result, "/api/users");
927    }
928
929    #[test]
930    fn test_substitute_context_numeric_value() {
931        let mut ctx = HashMap::new();
932        ctx.insert("id".to_string(), json!(42));
933        let result = faucet_core::util::substitute_context("/items/{id}", &ctx);
934        assert_eq!(result, "/items/42");
935    }
936
937    #[test]
938    fn test_parse_retry_after_valid() {
939        let mut headers = HeaderMap::new();
940        headers.insert(
941            reqwest::header::RETRY_AFTER,
942            reqwest::header::HeaderValue::from_static("30"),
943        );
944        assert_eq!(parse_retry_after(&headers), Duration::from_secs(30));
945    }
946
947    #[test]
948    fn test_parse_retry_after_missing_defaults_to_60() {
949        assert_eq!(
950            parse_retry_after(&HeaderMap::new()),
951            Duration::from_secs(60)
952        );
953    }
954
955    #[test]
956    fn test_parse_retry_after_non_numeric_defaults_to_60() {
957        let mut headers = HeaderMap::new();
958        headers.insert(
959            reqwest::header::RETRY_AFTER,
960            reqwest::header::HeaderValue::from_static("not-a-number"),
961        );
962        assert_eq!(parse_retry_after(&headers), Duration::from_secs(60));
963    }
964
965    #[test]
966    fn test_parse_retry_after_http_date() {
967        // RFC 7231 permits an HTTP-date form instead of delta-seconds.
968        let future = std::time::SystemTime::now() + Duration::from_secs(7200);
969        let date = httpdate::fmt_http_date(future);
970        let mut headers = HeaderMap::new();
971        headers.insert(
972            reqwest::header::RETRY_AFTER,
973            reqwest::header::HeaderValue::from_str(&date).unwrap(),
974        );
975        let d = parse_retry_after(&headers);
976        // ~2 hours out — must not collapse to the 60s fallback.
977        assert!(
978            d > Duration::from_secs(3600),
979            "expected ~2h from HTTP-date, got {d:?}"
980        );
981        assert!(
982            d <= Duration::from_secs(7200),
983            "should not exceed the target instant, got {d:?}"
984        );
985    }
986
987    #[test]
988    fn test_parse_retry_after_past_http_date_is_zero() {
989        // A date already in the past → retry now (zero wait), not the fallback.
990        let past = std::time::SystemTime::now() - Duration::from_secs(3600);
991        let date = httpdate::fmt_http_date(past);
992        let mut headers = HeaderMap::new();
993        headers.insert(
994            reqwest::header::RETRY_AFTER,
995            reqwest::header::HeaderValue::from_str(&date).unwrap(),
996        );
997        assert_eq!(parse_retry_after(&headers), Duration::ZERO);
998    }
999
1000    #[test]
1001    fn test_new_rejects_invalid_expiry_ratio_zero() {
1002        let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
1003            token_url: "https://auth.example.com/token".into(),
1004            client_id: "id".into(),
1005            client_secret: "secret".into(),
1006            scopes: vec![],
1007            expiry_ratio: 0.0,
1008        });
1009        let result = RestStream::new(config);
1010        assert!(result.is_err());
1011        assert!(matches!(result, Err(FaucetError::Auth(_))));
1012    }
1013
1014    #[test]
1015    fn test_new_rejects_invalid_expiry_ratio_negative() {
1016        let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
1017            token_url: "https://auth.example.com/token".into(),
1018            client_id: "id".into(),
1019            client_secret: "secret".into(),
1020            scopes: vec![],
1021            expiry_ratio: -0.5,
1022        });
1023        assert!(RestStream::new(config).is_err());
1024    }
1025
1026    #[test]
1027    fn test_new_rejects_invalid_expiry_ratio_above_one() {
1028        let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
1029            token_url: "https://auth.example.com/token".into(),
1030            client_id: "id".into(),
1031            client_secret: "secret".into(),
1032            scopes: vec![],
1033            expiry_ratio: 1.5,
1034        });
1035        assert!(RestStream::new(config).is_err());
1036    }
1037
1038    #[test]
1039    fn test_new_accepts_valid_expiry_ratio() {
1040        let config = RestStreamConfig::new("https://example.com", "/data").auth(Auth::OAuth2 {
1041            token_url: "https://auth.example.com/token".into(),
1042            client_id: "id".into(),
1043            client_secret: "secret".into(),
1044            scopes: vec![],
1045            expiry_ratio: 1.0,
1046        });
1047        assert!(RestStream::new(config).is_ok());
1048    }
1049
1050    #[test]
1051    fn test_new_with_no_auth_succeeds() {
1052        let config = RestStreamConfig::new("https://example.com", "/data");
1053        assert!(RestStream::new(config).is_ok());
1054    }
1055
1056    #[test]
1057    fn test_new_with_timeout() {
1058        let config =
1059            RestStreamConfig::new("https://example.com", "/data").timeout(Duration::from_secs(10));
1060        assert!(RestStream::new(config).is_ok());
1061    }
1062
1063    #[test]
1064    fn test_substitute_context_missing_placeholder_unchanged() {
1065        let mut ctx = HashMap::new();
1066        ctx.insert("org".to_string(), json!("acme"));
1067        let result = faucet_core::util::substitute_context("/items/{missing}", &ctx);
1068        assert_eq!(result, "/items/{missing}");
1069    }
1070
1071    #[test]
1072    fn test_substitute_context_boolean_value() {
1073        let mut ctx = HashMap::new();
1074        ctx.insert("flag".to_string(), json!(true));
1075        let result = faucet_core::util::substitute_context("/items/{flag}", &ctx);
1076        assert_eq!(result, "/items/true");
1077    }
1078
1079    #[test]
1080    fn rest_source_connector_name_is_rest() {
1081        use faucet_core::Source;
1082        let source = RestStream::new(RestStreamConfig::new("https://example.com", "/data"))
1083            .expect("minimal RestStream construction");
1084        assert_eq!(source.connector_name(), "rest");
1085    }
1086
1087    #[test]
1088    fn dataset_uri_combines_base_and_path() {
1089        use faucet_core::Source;
1090        let source = RestStream::new(RestStreamConfig::new(
1091            "https://api.example.com",
1092            "/v1/users",
1093        ))
1094        .unwrap();
1095        assert_eq!(source.dataset_uri(), "https://api.example.com/v1/users");
1096    }
1097
1098    #[test]
1099    fn dataset_uri_redacts_credentials() {
1100        use faucet_core::Source;
1101        let source = RestStream::new(RestStreamConfig::new(
1102            "https://user:secret@api.example.com",
1103            "/v1/data",
1104        ))
1105        .unwrap();
1106        assert_eq!(source.dataset_uri(), "https://api.example.com/v1/data");
1107    }
1108}