Skip to main content

faucet_source_rest/
config.rs

1//! Stream configuration and builder.
2
3use crate::auth::Auth;
4use crate::pagination::PaginationStyle;
5use faucet_core::AuthSpec;
6use faucet_core::{ReplicationBind, ReplicationMethod};
7use reqwest::{
8    Method,
9    header::{HeaderMap, HeaderName, HeaderValue},
10};
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::HashMap;
15use std::time::Duration;
16
17/// How to parse the response body into records.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
19#[serde(rename_all = "snake_case")]
20pub enum ResponseFormat {
21    /// JSON — extract records via `records_path` (JSONPath). The default.
22    #[default]
23    Json,
24    /// CSV — parse a tabular file body (each row → a JSON object). For
25    /// authenticated file endpoints (e.g. an export URL). Always available.
26    Csv,
27    /// Excel (`.xlsx`/`.xls`) — parse a workbook body. Requires the crate's
28    /// `excel` feature.
29    Excel,
30}
31
32fn default_csv_delimiter() -> u8 {
33    b','
34}
35fn default_csv_has_headers() -> bool {
36    true
37}
38
39/// Configuration for a RestStream.
40#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
41pub struct RestStreamConfig {
42    // ── Core request ──────────────────────────────────────────────────────────
43    pub base_url: String,
44    /// URL path, relative to `base_url`. May contain `{key}` placeholders that
45    /// are substituted per-partition (e.g. `"/orgs/{org_id}/users"`).
46    pub path: String,
47    #[serde(with = "crate::serde_helpers::http_method")]
48    #[schemars(with = "String")]
49    pub method: Method,
50    /// Authentication: either inline (`{ type, config }`) or a `{ ref: <name> }`
51    /// pointer to a shared provider in the CLI's top-level `auth:` catalog.
52    pub auth: AuthSpec<Auth>,
53    /// Static request headers sent on **every** request (data pages, async-job
54    /// submit/poll/fetch requests, and OData `$metadata` discovery probes).
55    /// Applied *before* the auth provider's header placements, so an auth
56    /// header of the same name always wins on a clash. Values honor
57    /// `${env:}` / `${param.*}` load-time interpolation and pass through the
58    /// secrets/redaction boundary like other config strings. Invalid header
59    /// names/values are rejected at config load
60    /// ([`FaucetError::Config`](faucet_core::FaucetError::Config)), never a
61    /// mid-run panic.
62    ///
63    /// ```yaml
64    /// headers:
65    ///   Prefer: transient
66    ///   Accept: application/json
67    /// ```
68    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
69    #[schemars(with = "std::collections::HashMap<String, String>")]
70    pub headers: HashMap<String, String>,
71    pub query_params: HashMap<String, String>,
72    /// Repeated / array-valued query params (#536), rendered as repeated keys —
73    /// e.g. `{ "group_by[]": ["api_key_id", "model"] }` → `?group_by[]=api_key_id&group_by[]=model`.
74    /// Applied alongside (in addition to) [`query_params`](Self::query_params);
75    /// use this for APIs that need a key to appear more than once (`group_by[]`,
76    /// repeated `expand`/`fields`). Values honor `{placeholder}` context
77    /// substitution for child sources, like `query_params`. Empty by default.
78    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
79    pub query_params_multi: HashMap<String, Vec<String>>,
80    pub body: Option<Value>,
81
82    // ── Pagination ────────────────────────────────────────────────────────────
83    pub pagination: PaginationStyle,
84    pub records_path: Option<String>,
85    pub max_pages: Option<usize>,
86    #[serde(with = "faucet_core::config::duration_secs_option", default)]
87    #[schemars(with = "Option<u64>")]
88    pub request_delay: Option<Duration>,
89
90    // ── Reliability ───────────────────────────────────────────────────────────
91    #[serde(with = "faucet_core::config::duration_secs_option", default)]
92    #[schemars(with = "Option<u64>")]
93    pub timeout: Option<Duration>,
94    /// Number of retries (after the first attempt) for transient request
95    /// failures. Default `3`.
96    ///
97    /// **Precedence note:** the REST source predates the unified pipeline
98    /// `resilience:` policy. When this field (or [`retry_backoff`](Self::retry_backoff))
99    /// is left at its default, an injected `RetryPolicy` (e.g. from a
100    /// pipeline-level `resilience:` block, via
101    /// [`RestStream::with_retry_policy`](crate::RestStream::with_retry_policy))
102    /// governs the retry budget. Setting this field away from its default makes
103    /// it win — an explicit per-connector value is never silently overridden by
104    /// a pipeline-wide default.
105    pub max_retries: u32,
106    /// Base exponential-backoff delay between retries. Default `1s`. Shares the
107    /// legacy-field precedence rule documented on [`max_retries`](Self::max_retries).
108    #[serde(with = "faucet_core::config::duration_secs")]
109    #[schemars(with = "u64")]
110    pub retry_backoff: Duration,
111    /// HTTP status codes that should **not** cause an error. Responses with
112    /// these codes are treated as empty pages (no records, no further pages).
113    pub tolerated_http_errors: Vec<u16>,
114
115    // ── Replication ───────────────────────────────────────────────────────────
116    pub replication_method: ReplicationMethod,
117    /// Field name (not a JSONPath) used for incremental replication bookmarking.
118    pub replication_key: Option<String>,
119    /// Bookmark value: records where `record[replication_key] <= start_replication_value`
120    /// are filtered out when `replication_method` is `Incremental`.
121    pub start_replication_value: Option<Value>,
122    /// Opt-in identifier used by [`Pipeline::with_state_store`](faucet_core::Pipeline::with_state_store)
123    /// to persist this stream's bookmark across runs. When set, the pipeline
124    /// will load any previously-stored bookmark before fetching and write the
125    /// new bookmark only after the sink confirms the batch.
126    ///
127    /// Keys must satisfy [`faucet_core::state::validate_state_key`].
128    pub state_key: Option<String>,
129
130    // ── Singer / Meltano metadata ─────────────────────────────────────────────
131    /// Human-readable stream name (used in logging and Singer SCHEMA messages).
132    pub name: Option<String>,
133    /// Field names that uniquely identify a record (Singer `key_properties`).
134    pub primary_keys: Vec<String>,
135    /// JSON Schema describing the structure of each record.
136    pub schema: Option<Value>,
137    /// Maximum number of records to sample when inferring the schema via
138    /// [`crate::stream::RestStream::infer_schema`].  `0` means sample all
139    /// available records (up to `max_pages`).  Defaults to `100`.
140    pub schema_sample_size: usize,
141
142    // ── Partitions ────────────────────────────────────────────────────────────
143    /// Each entry is a context map whose values are substituted into `path`
144    /// placeholders. The stream is executed once per partition and results are
145    /// concatenated.  Empty means run once with no substitution.
146    pub partitions: Vec<HashMap<String, Value>>,
147    /// Maximum number of partitions to fetch concurrently.
148    /// `None` means sequential processing (backward compatible default).
149    pub partition_concurrency: Option<usize>,
150
151    // ── Mutual TLS ─────────────────────────────────────────────────────────────
152    /// Optional client-certificate (mutual TLS) config. When set, the source
153    /// presents a client certificate on **every** request — data requests and
154    /// any inline auth token request (both go through the same HTTP client).
155    /// Requires the crate's `mtls` feature; a `tls` block on a build without it
156    /// is a load-time error rather than being silently ignored.
157    #[serde(default)]
158    pub tls: Option<TlsClientConfig>,
159
160    // ── Response format (#497) ─────────────────────────────────────────────────
161    /// How to parse the response body. `json` (default) uses JSONPath
162    /// extraction (`records_path`); `csv` / `excel` parse a tabular **file**
163    /// body into records — for authenticated file endpoints such as a Microsoft
164    /// Graph / OneDrive / SharePoint `…/content` download or any signed export
165    /// URL. In file mode a single response is fetched (pagination must be
166    /// `none`) and `records_path` does not apply. `excel` requires the crate's
167    /// `excel` feature.
168    #[serde(default)]
169    pub response_format: ResponseFormat,
170    /// CSV field delimiter byte (default `,`). Used only when
171    /// `response_format: csv`.
172    #[serde(default = "default_csv_delimiter")]
173    pub csv_delimiter: u8,
174    /// Whether the first CSV row is a header row supplying field names
175    /// (default `true`). When `false`, fields are named `column_0`, `column_1`, …
176    #[serde(default = "default_csv_has_headers")]
177    pub csv_has_headers: bool,
178    /// Excel worksheet to read: a sheet name, or a 0-based index as a string.
179    /// When omitted, the first worksheet is used. `response_format: excel` only.
180    #[serde(default)]
181    pub excel_sheet: Option<String>,
182    /// 0-based index of the Excel header row (default `0`). Rows above it are
183    /// skipped; the header row supplies field names. `response_format: excel` only.
184    #[serde(default)]
185    pub excel_header_row: usize,
186
187    // ── Server-side incremental push-down (#513) ────────────────────────────────
188    /// Bind the stored bookmark into the outgoing request (query param / header /
189    /// body field / path) so the server returns only new rows. Composes with the
190    /// existing `replication_key` client-side filter, which stays active as a
191    /// safety net. Requires `replication_method: incremental` + `replication_key`.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub replication_bind: Option<ReplicationBind>,
194
195    // ── OData (#512) ────────────────────────────────────────────────────────────
196    /// Speak the OData protocol: `@odata.nextLink` paging, the `$.value`
197    /// envelope, `$select`/`$filter`/`$expand`/`$orderby` sugar, and
198    /// `$metadata` (EDMX) → schema discovery. When set, it derives the
199    /// pagination, `records_path`, query params, and `Prefer` header at load
200    /// time (explicit values still win). See [`ODataConfig`].
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub odata: Option<ODataConfig>,
203
204    // ── Response-decode pipeline (#515) ─────────────────────────────────────────
205    /// Decode the response body before record extraction: a chain of
206    /// `extract` (JSONPath) / `base64` / `gunzip` / `unzip` / `parse`
207    /// (json|csv|xlsx|xml) steps. Lets a source consume base64/compressed/file
208    /// payloads (e.g. a base64 XLSX inside a SOAP body, or a gzipped-CSV export).
209    /// When set, it replaces the `response_format` body parsing, and pagination
210    /// must be `none`. See [`DecodeStep`](crate::decode::DecodeStep).
211    #[serde(default, skip_serializing_if = "Vec::is_empty")]
212    pub decode: Vec<crate::decode::DecodeStep>,
213
214    // ── Async-job pattern (#514) ────────────────────────────────────────────────
215    /// Run a submit→poll→fetch job lifecycle instead of a single GET, for
216    /// bulk/export/report-run APIs (Salesforce Bulk, Stripe Reporting, …). The
217    /// fetched result flows through `decode:` / `response_format`. When set,
218    /// pagination must be `none`. See [`AsyncJobConfig`](crate::async_job::AsyncJobConfig).
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub async_job: Option<crate::async_job::AsyncJobConfig>,
221
222    // ── In-run datetime window slicing (#527) ───────────────────────────────────
223    /// Bound each request to a rolling `[start, end)` window between the stored
224    /// bookmark and `now`, iterating the windows within one run (each `step`
225    /// wide) with per-window bookmark durability. For APIs that require — or cap —
226    /// a bounded date range (analytics/ads/reporting feeds). Parity with Airbyte's
227    /// `DatetimeBasedCursor`. Requires `replication_method: incremental` +
228    /// `replication_key`, and a start bookmark (from state, or
229    /// `start_replication_value`). See [`WindowSpec`](faucet_core::WindowSpec).
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub window: Option<faucet_core::WindowSpec>,
232
233    // ── Envelope-ancestor lifting (#549) ────────────────────────────────────────
234    /// When `records_path` selects a **nested** array element (e.g.
235    /// `$.data[*].data.object`), copy fields from the enclosing `[*]`
236    /// array-element ancestor onto each emitted record. The map is
237    /// `dest_field: ancestor_relative_path` — for each matched leaf, the source
238    /// walks up to the array-element ancestor and copies the named path onto the
239    /// record under `dest_field`. Absent ⇒ records are emitted unchanged.
240    ///
241    /// ```yaml
242    /// records_path: "$.data[*].data.object"
243    /// record_ancestors: { event_id: "id", event_created: "created" }
244    /// ```
245    ///
246    /// Requires `records_path` to contain an array wildcard `[*]`; mutually
247    /// exclusive with [`records_multi`](Self::records_multi).
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub record_ancestors: Option<HashMap<String, String>>,
250
251    // ── Multi-array fan-out (#548) ──────────────────────────────────────────────
252    /// Emit several record arrays from one response in a single page (sharing one
253    /// pagination advance), each stamped with a user-defined op marker under
254    /// [`op_field`](Self::op_field). Composes with a downstream
255    /// `write_mode: upsert` + `delete_marker` so added/modified/removed feeds
256    /// route correctly. Mutually exclusive with `records_path` /
257    /// `record_ancestors`, and requires `response_format: json` with no
258    /// `decode:` pipeline.
259    ///
260    /// ```yaml
261    /// records_multi:
262    ///   - { path: "$.added[*]",    op: upsert }
263    ///   - { path: "$.modified[*]", op: upsert }
264    ///   - { path: "$.removed[*]",  op: delete }
265    /// op_field: _op
266    /// ```
267    #[serde(default, skip_serializing_if = "Vec::is_empty")]
268    pub records_multi: Vec<RecordsMultiSpec>,
269    /// Field name each [`records_multi`](Self::records_multi) record is stamped
270    /// with its spec's `op` value. Defaults to `_op` when omitted.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub op_field: Option<String>,
273
274    // ── Resumable cursor (#547) ─────────────────────────────────────────────────
275    /// Persist the terminal pagination cursor as this run's bookmark (riding the
276    /// existing `StreamPage.bookmark` / `StateStore` path — no core trait change)
277    /// and, on resume, seed the stored bookmark back into the first request
278    /// (query param for `cursor`, request body field for `cursor_in_body`) before
279    /// paging. Only meaningful with `pagination: cursor` / `cursor_in_body`;
280    /// mutually exclusive with `window` slicing. Default `false`.
281    #[serde(default)]
282    pub persist_cursor: bool,
283}
284
285/// One entry in a [`RestStreamConfig::records_multi`] fan-out (#548): a JSONPath
286/// selecting an array of records, plus the op marker each is stamped with.
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
288#[serde(deny_unknown_fields)]
289pub struct RecordsMultiSpec {
290    /// JSONPath selecting an array of records (e.g. `"$.added[*]"`).
291    pub path: String,
292    /// Op marker stamped onto each record from `path` under
293    /// [`RestStreamConfig::op_field`] (e.g. `upsert` / `delete`, or `u` / `d`).
294    pub op: String,
295}
296
297/// OData protocol version, which selects the paging-link key.
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
299#[serde(rename_all = "lowercase")]
300pub enum ODataVersion {
301    /// OData v2 — JSON-light next link `odata.nextLink`.
302    V2,
303    /// OData v4 (default) — next link `@odata.nextLink`.
304    #[default]
305    V4,
306}
307
308impl ODataVersion {
309    /// JSONPath to the next-page link for this version.
310    pub fn next_link_path(self) -> &'static str {
311        match self {
312            // Bracketed single-quoted keys — `@`/`.` aren't bare-identifier
313            // chars, and jsonpath-rust wants `$['key']`, not `$."key"`.
314            ODataVersion::V2 => "$['odata.nextLink']",
315            ODataVersion::V4 => "$['@odata.nextLink']",
316        }
317    }
318}
319
320/// OData protocol options for the REST source (#512).
321///
322/// A minimal block — `{ entity: Orders }` — is enough; it derives paging,
323/// the `$.value` envelope, and (for `faucet discover`) `$metadata` parsing.
324/// The query-option fields render into the standard `$select`/`$filter`/
325/// `$expand`/`$orderby` params.
326#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
327#[serde(deny_unknown_fields)]
328pub struct ODataConfig {
329    /// Protocol version (default `v4`).
330    #[serde(default)]
331    pub version: ODataVersion,
332    /// Entity set to read (appended to `base_url` as the path, e.g. `Orders`).
333    /// Optional when the path already names the entity.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub entity: Option<String>,
336    /// `$select` — columns to return.
337    #[serde(default, skip_serializing_if = "Vec::is_empty")]
338    pub select: Vec<String>,
339    /// `$expand` — related entities to inline (one level).
340    #[serde(default, skip_serializing_if = "Vec::is_empty")]
341    pub expand: Vec<String>,
342    /// `$filter` — server-side filter expression (verbatim).
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub filter: Option<String>,
345    /// `$orderby` — server-side ordering (verbatim).
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub orderby: Option<String>,
348    /// Server page size, sent as `Prefer: odata.maxpagesize=<n>`.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub page_size: Option<usize>,
351}
352
353pub use faucet_core::TlsClientConfig;
354
355/// Build a validated [`HeaderMap`] from the static `headers` string map.
356///
357/// Invalid header names/values become a typed
358/// [`FaucetError::Config`](faucet_core::FaucetError::Config) so a malformed
359/// header fails at config load rather than panicking mid-run. Used both by
360/// [`RestStreamConfig::validate`] (to fail loudly at load) and by the request
361/// path (which reuses the already-validated map).
362pub(crate) fn build_header_map(
363    headers: &HashMap<String, String>,
364) -> Result<HeaderMap, faucet_core::FaucetError> {
365    let mut map = HeaderMap::with_capacity(headers.len());
366    for (name, value) in headers {
367        let hn = HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
368            faucet_core::FaucetError::Config(format!("rest: invalid header name '{name}': {e}"))
369        })?;
370        let hv = HeaderValue::from_str(value).map_err(|e| {
371            faucet_core::FaucetError::Config(format!(
372                "rest: invalid value for header '{name}': {e}"
373            ))
374        })?;
375        map.insert(hn, hv);
376    }
377    Ok(map)
378}
379
380impl Default for RestStreamConfig {
381    fn default() -> Self {
382        Self {
383            base_url: String::new(),
384            path: String::new(),
385            method: Method::GET,
386            auth: AuthSpec::Inline(Auth::None),
387            headers: HashMap::new(),
388            query_params: HashMap::new(),
389            query_params_multi: HashMap::new(),
390            body: None,
391            pagination: PaginationStyle::None,
392            records_path: None,
393            max_pages: Some(100),
394            request_delay: None,
395            timeout: Some(Duration::from_secs(30)),
396            max_retries: 3,
397            retry_backoff: Duration::from_secs(1),
398            tolerated_http_errors: Vec::new(),
399            replication_method: ReplicationMethod::FullTable,
400            replication_key: None,
401            start_replication_value: None,
402            state_key: None,
403            name: None,
404            primary_keys: Vec::new(),
405            schema: None,
406            schema_sample_size: 100,
407            partitions: Vec::new(),
408            partition_concurrency: None,
409            tls: None,
410            response_format: ResponseFormat::Json,
411            csv_delimiter: b',',
412            csv_has_headers: true,
413            excel_sheet: None,
414            excel_header_row: 0,
415            replication_bind: None,
416            odata: None,
417            decode: Vec::new(),
418            async_job: None,
419            window: None,
420            record_ancestors: None,
421            records_multi: Vec::new(),
422            op_field: None,
423            persist_cursor: false,
424        }
425    }
426}
427
428impl RestStreamConfig {
429    /// Validate cross-field invariants that serde alone can't express.
430    ///
431    /// File response formats (`csv` / `excel`) fetch a single response and
432    /// parse the whole body, so paginated / JSONPath-extracted requests are
433    /// rejected rather than silently ignored.
434    pub fn validate(&self) -> Result<(), faucet_core::FaucetError> {
435        // Static custom headers: reject an invalid header name/value at load
436        // time rather than panicking on the first request (#539).
437        build_header_map(&self.headers)?;
438        if !matches!(self.response_format, ResponseFormat::Json) {
439            if !matches!(self.pagination, PaginationStyle::None) {
440                return Err(faucet_core::FaucetError::Config(
441                    "rest: `response_format: csv|excel` fetches a single file body and does not \
442                     paginate — set `pagination: none`"
443                        .into(),
444                ));
445            }
446            if self.records_path.is_some() {
447                return Err(faucet_core::FaucetError::Config(
448                    "rest: `records_path` (JSONPath) does not apply to `response_format: csv|excel` \
449                     — the whole file body becomes the record set"
450                        .into(),
451                ));
452            }
453        }
454        if let Some(bind) = &self.replication_bind {
455            bind.validate()?;
456            if !matches!(self.replication_method, ReplicationMethod::Incremental) {
457                return Err(faucet_core::FaucetError::Config(
458                    "rest: `replication_bind` requires `replication_method: incremental`".into(),
459                ));
460            }
461            if self.replication_key.is_none() {
462                return Err(faucet_core::FaucetError::Config(
463                    "rest: `replication_bind` requires `replication_key` (the field whose \
464                     bookmark is pushed down)"
465                        .into(),
466                ));
467            }
468        }
469        if self.odata.is_some() && !matches!(self.response_format, ResponseFormat::Json) {
470            return Err(faucet_core::FaucetError::Config(
471                "rest: `odata` speaks JSON — remove `response_format: csv|excel`".into(),
472            ));
473        }
474        if !self.decode.is_empty() {
475            if !matches!(self.pagination, PaginationStyle::None) {
476                return Err(faucet_core::FaucetError::Config(
477                    "rest: a `decode:` pipeline consumes a single response body — set \
478                     `pagination: none`"
479                        .into(),
480                ));
481            }
482            if !matches!(self.response_format, ResponseFormat::Json) {
483                return Err(faucet_core::FaucetError::Config(
484                    "rest: `decode:` replaces `response_format` body parsing — remove \
485                     `response_format: csv|excel`"
486                        .into(),
487                ));
488            }
489        }
490        if let Some(job) = &self.async_job {
491            job.validate()?;
492            if !matches!(self.pagination, PaginationStyle::None) {
493                return Err(faucet_core::FaucetError::Config(
494                    "rest: an `async_job:` lifecycle fetches a single result — set \
495                     `pagination: none`"
496                        .into(),
497                ));
498            }
499        }
500        if let Some(window) = &self.window {
501            window.validate()?;
502            if !matches!(self.replication_method, ReplicationMethod::Incremental) {
503                return Err(faucet_core::FaucetError::Config(
504                    "rest: `window` slicing requires `replication_method: incremental`".into(),
505                ));
506            }
507            if self.replication_key.is_none() {
508                return Err(faucet_core::FaucetError::Config(
509                    "rest: `window` slicing requires `replication_key` (the datetime cursor field)"
510                        .into(),
511                ));
512            }
513            if self.async_job.is_some() {
514                return Err(faucet_core::FaucetError::Config(
515                    "rest: `window` slicing and `async_job` are mutually exclusive — the async-job \
516                     lifecycle fetches a single result and does not slice by window"
517                        .into(),
518                ));
519            }
520        }
521        // #548: multi-array fan-out is its own extraction mode.
522        if !self.records_multi.is_empty() {
523            if self.records_path.is_some() {
524                return Err(faucet_core::FaucetError::Config(
525                    "rest: `records_multi` and `records_path` are mutually exclusive — \
526                     `records_multi` names the arrays itself"
527                        .into(),
528                ));
529            }
530            if self.record_ancestors.is_some() {
531                return Err(faucet_core::FaucetError::Config(
532                    "rest: `records_multi` and `record_ancestors` are mutually exclusive".into(),
533                ));
534            }
535            if !matches!(self.response_format, ResponseFormat::Json) {
536                return Err(faucet_core::FaucetError::Config(
537                    "rest: `records_multi` extracts JSON arrays — remove `response_format: csv|excel`"
538                        .into(),
539                ));
540            }
541            if !self.decode.is_empty() {
542                return Err(faucet_core::FaucetError::Config(
543                    "rest: `records_multi` and a `decode:` pipeline are mutually exclusive".into(),
544                ));
545            }
546            for spec in &self.records_multi {
547                if spec.path.trim().is_empty() {
548                    return Err(faucet_core::FaucetError::Config(
549                        "rest: each `records_multi[].path` must not be empty".into(),
550                    ));
551                }
552                if spec.op.trim().is_empty() {
553                    return Err(faucet_core::FaucetError::Config(
554                        "rest: each `records_multi[].op` must not be empty".into(),
555                    ));
556                }
557            }
558            if let Some(f) = &self.op_field
559                && f.trim().is_empty()
560            {
561                return Err(faucet_core::FaucetError::Config(
562                    "rest: `op_field` must not be empty".into(),
563                ));
564            }
565        } else if self.op_field.is_some() {
566            return Err(faucet_core::FaucetError::Config(
567                "rest: `op_field` only applies to `records_multi`".into(),
568            ));
569        }
570        // #549: envelope-ancestor lifting requires a nested array records_path.
571        if let Some(anc) = &self.record_ancestors
572            && !anc.is_empty()
573        {
574            match &self.records_path {
575                Some(rp) if rp.contains("[*]") => {}
576                Some(_) => {
577                    return Err(faucet_core::FaucetError::Config(
578                        "rest: `record_ancestors` requires `records_path` to select a nested array \
579                         element (a path containing `[*]`, e.g. `$.data[*].data.object`)"
580                            .into(),
581                    ));
582                }
583                None => {
584                    return Err(faucet_core::FaucetError::Config(
585                        "rest: `record_ancestors` requires `records_path`".into(),
586                    ));
587                }
588            }
589            for (dest, rel) in anc {
590                if dest.trim().is_empty() {
591                    return Err(faucet_core::FaucetError::Config(
592                        "rest: `record_ancestors` destination field names must not be empty".into(),
593                    ));
594                }
595                if rel.trim().is_empty() {
596                    return Err(faucet_core::FaucetError::Config(
597                        "rest: `record_ancestors` ancestor paths must not be empty".into(),
598                    ));
599                }
600            }
601        }
602        // #547: resumable cursor is only meaningful for cursor pagination.
603        if self.persist_cursor {
604            if !matches!(
605                self.pagination,
606                PaginationStyle::Cursor { .. } | PaginationStyle::CursorInBody { .. }
607            ) {
608                return Err(faucet_core::FaucetError::Config(
609                    "rest: `persist_cursor` requires `pagination: cursor` or `cursor_in_body`"
610                        .into(),
611                ));
612            }
613            if self.window.is_some() {
614                return Err(faucet_core::FaucetError::Config(
615                    "rest: `persist_cursor` and `window` slicing are mutually exclusive".into(),
616                ));
617            }
618        }
619        Ok(())
620    }
621
622    /// Derive request defaults from the `odata:` block (paging, `$.value`
623    /// envelope, `$select`/`$filter`/`$expand`/`$orderby` params, and the
624    /// `Prefer` page-size header). Explicit config always wins — a field the
625    /// user already set is never overwritten. Idempotent.
626    pub fn apply_odata_defaults(&mut self) {
627        let Some(odata) = self.odata.clone() else {
628            return;
629        };
630        // Entity → path when the path doesn't already name one.
631        if self.path.trim_matches('/').is_empty()
632            && let Some(entity) = &odata.entity
633        {
634            self.path = entity.clone();
635        }
636        // OData records live under `$.value`.
637        if self.records_path.is_none() {
638            self.records_path = Some("$.value[*]".to_owned());
639        }
640        // Follow `@odata.nextLink` (v4) / `odata.nextLink` (v2).
641        if matches!(self.pagination, PaginationStyle::None) {
642            self.pagination = PaginationStyle::NextLinkInBody {
643                next_link_path: odata.version.next_link_path().to_owned(),
644            };
645        }
646        // Query-option sugar → standard params (don't clobber explicit ones).
647        let mut set_param = |k: &str, v: String| {
648            self.query_params.entry(k.to_owned()).or_insert(v);
649        };
650        if !odata.select.is_empty() {
651            set_param("$select", odata.select.join(","));
652        }
653        if !odata.expand.is_empty() {
654            set_param("$expand", odata.expand.join(","));
655        }
656        if let Some(filter) = &odata.filter {
657            set_param("$filter", filter.clone());
658        }
659        if let Some(orderby) = &odata.orderby {
660            set_param("$orderby", orderby.clone());
661        }
662        // Server page size via the `Prefer` header (case-insensitive check so a
663        // user-set `Prefer:` is not double-inserted).
664        if let Some(n) = odata.page_size
665            && !self
666                .headers
667                .keys()
668                .any(|k| k.eq_ignore_ascii_case("prefer"))
669        {
670            self.headers
671                .insert("prefer".to_owned(), format!("odata.maxpagesize={n}"));
672        }
673    }
674
675    pub fn new(base_url: &str, path: &str) -> Self {
676        Self {
677            base_url: base_url.trim_end_matches('/').to_string(),
678            path: path.to_string(),
679            ..Default::default()
680        }
681    }
682
683    // ── Core request ──────────────────────────────────────────────────────────
684
685    pub fn method(mut self, m: Method) -> Self {
686        self.method = m;
687        self
688    }
689
690    pub fn auth(mut self, a: Auth) -> Self {
691        self.auth = AuthSpec::Inline(a);
692        self
693    }
694
695    /// Add a static request header. Validation is deferred to
696    /// [`RestStream::new`](crate::RestStream::new) (via [`validate`](Self::validate)),
697    /// so an invalid name/value surfaces as a typed
698    /// [`FaucetError::Config`](faucet_core::FaucetError::Config) rather than
699    /// panicking here.
700    pub fn header(mut self, k: &str, v: &str) -> Self {
701        self.headers.insert(k.to_string(), v.to_string());
702        self
703    }
704
705    pub fn query(mut self, k: &str, v: &str) -> Self {
706        self.query_params.insert(k.into(), v.into());
707        self
708    }
709
710    pub fn body(mut self, b: Value) -> Self {
711        self.body = Some(b);
712        self
713    }
714
715    /// Attach a mutual-TLS client identity (requires the `mtls` feature at build
716    /// time; otherwise [`RestStream::new`](crate::RestStream::new) errors).
717    pub fn tls(mut self, tls: TlsClientConfig) -> Self {
718        self.tls = Some(tls);
719        self
720    }
721
722    // ── Pagination ────────────────────────────────────────────────────────────
723
724    pub fn pagination(mut self, p: PaginationStyle) -> Self {
725        self.pagination = p;
726        self
727    }
728
729    pub fn records_path(mut self, p: &str) -> Self {
730        self.records_path = Some(p.into());
731        self
732    }
733
734    pub fn max_pages(mut self, n: usize) -> Self {
735        self.max_pages = Some(n);
736        self
737    }
738
739    pub fn request_delay(mut self, d: Duration) -> Self {
740        self.request_delay = Some(d);
741        self
742    }
743
744    // ── Reliability ───────────────────────────────────────────────────────────
745
746    pub fn timeout(mut self, d: Duration) -> Self {
747        self.timeout = Some(d);
748        self
749    }
750
751    pub fn max_retries(mut self, n: u32) -> Self {
752        self.max_retries = n;
753        self
754    }
755
756    pub fn retry_backoff(mut self, d: Duration) -> Self {
757        self.retry_backoff = d;
758        self
759    }
760
761    /// HTTP status codes that should be silently ignored (treated as empty pages).
762    pub fn tolerate_http_error(mut self, status: u16) -> Self {
763        self.tolerated_http_errors.push(status);
764        self
765    }
766
767    // ── Replication ───────────────────────────────────────────────────────────
768
769    pub fn replication_method(mut self, m: ReplicationMethod) -> Self {
770        self.replication_method = m;
771        self
772    }
773
774    /// Field name (not JSONPath) used as the incremental replication bookmark.
775    pub fn replication_key(mut self, key: &str) -> Self {
776        self.replication_key = Some(key.into());
777        self
778    }
779
780    /// Bookmark start value: records at or before this value are filtered out
781    /// when using `ReplicationMethod::Incremental`.
782    pub fn start_replication_value(mut self, v: Value) -> Self {
783        self.start_replication_value = Some(v);
784        self
785    }
786
787    /// Opt the stream into resumable runs by giving it a stable state key.
788    /// When this is set and the [`Pipeline`](faucet_core::Pipeline) is
789    /// configured with a state store, the previously persisted bookmark is
790    /// applied to the stream before fetching.
791    pub fn state_key(mut self, key: &str) -> Self {
792        self.state_key = Some(key.into());
793        self
794    }
795
796    /// Bind the stored bookmark into the outgoing request (#513).
797    pub fn replication_bind(mut self, bind: ReplicationBind) -> Self {
798        self.replication_bind = Some(bind);
799        self
800    }
801
802    /// Slice the run into rolling `[start, end)` datetime windows (#527).
803    pub fn window(mut self, window: faucet_core::WindowSpec) -> Self {
804        self.window = Some(window);
805        self
806    }
807
808    /// Speak OData: derive paging, the `$.value` envelope, the query-option
809    /// sugar, and `$metadata` discovery from the block (#512).
810    pub fn odata(mut self, odata: ODataConfig) -> Self {
811        self.odata = Some(odata);
812        self
813    }
814
815    /// Set the response-decode pipeline (#515).
816    pub fn decode(mut self, steps: Vec<crate::decode::DecodeStep>) -> Self {
817        self.decode = steps;
818        self
819    }
820
821    // ── Singer / Meltano metadata ─────────────────────────────────────────────
822
823    /// Human-readable stream name.
824    pub fn name(mut self, n: &str) -> Self {
825        self.name = Some(n.into());
826        self
827    }
828
829    /// Field names that uniquely identify a record (Singer `key_properties`).
830    pub fn primary_keys(mut self, keys: Vec<String>) -> Self {
831        self.primary_keys = keys;
832        self
833    }
834
835    /// JSON Schema for the stream's records.
836    pub fn schema(mut self, s: Value) -> Self {
837        self.schema = Some(s);
838        self
839    }
840
841    /// Maximum records to sample for schema inference (`0` = unlimited).
842    pub fn schema_sample_size(mut self, n: usize) -> Self {
843        self.schema_sample_size = n;
844        self
845    }
846
847    // ── Partitions ────────────────────────────────────────────────────────────
848
849    /// Add a partition context. The stream will execute once for each partition,
850    /// substituting `{key}` placeholders in `path` with values from the context.
851    pub fn add_partition(mut self, ctx: HashMap<String, Value>) -> Self {
852        self.partitions.push(ctx);
853        self
854    }
855
856    /// Add a repeated / array-valued query parameter (#536): `key` is emitted
857    /// once per value (`?key=v0&key=v1`). Chainable.
858    pub fn add_query_param_multi(mut self, key: &str, values: Vec<String>) -> Self {
859        self.query_params_multi.insert(key.to_string(), values);
860        self
861    }
862
863    /// Set the maximum number of partitions to fetch concurrently.
864    /// `None` (default) means sequential processing.
865    pub fn partition_concurrency(mut self, concurrency: Option<usize>) -> Self {
866        self.partition_concurrency = concurrency;
867        self
868    }
869
870    // ── Extraction / cursor extras ──────────────────────────────────────────────
871
872    /// Copy enclosing `[*]` ancestor fields onto each nested record (#549).
873    pub fn record_ancestors(mut self, map: HashMap<String, String>) -> Self {
874        self.record_ancestors = Some(map);
875        self
876    }
877
878    /// Emit several op-stamped record arrays from one response in one page (#548).
879    pub fn records_multi(mut self, specs: Vec<RecordsMultiSpec>) -> Self {
880        self.records_multi = specs;
881        self
882    }
883
884    /// Field name each [`records_multi`](Self::records_multi) record is stamped
885    /// with its op value (default `_op`).
886    pub fn op_field(mut self, field: &str) -> Self {
887        self.op_field = Some(field.into());
888        self
889    }
890
891    /// Persist the terminal pagination cursor as the run's bookmark and seed it
892    /// on resume (#547).
893    pub fn persist_cursor(mut self, enabled: bool) -> Self {
894        self.persist_cursor = enabled;
895        self
896    }
897}
898
899#[cfg(test)]
900mod tests {
901    use super::*;
902    use faucet_core::{BindFormat, BindTarget, ReplicationBind};
903
904    fn bind() -> ReplicationBind {
905        ReplicationBind {
906            into: BindTarget::Query,
907            name: "since".to_owned(),
908            template: "${bookmark}".to_owned(),
909            format: BindFormat::Raw,
910            advance_from: None,
911        }
912    }
913
914    #[test]
915    fn replication_bind_requires_incremental_and_key() {
916        // Bind without incremental method → error.
917        let mut c = RestStreamConfig::new("https://x", "/y");
918        c.replication_bind = Some(bind());
919        assert!(c.validate().is_err());
920
921        // Incremental but no replication_key → error.
922        c.replication_method = ReplicationMethod::Incremental;
923        assert!(c.validate().is_err());
924
925        // Incremental + key → ok.
926        c.replication_key = Some("updated_at".to_owned());
927        assert!(c.validate().is_ok());
928
929        // An invalid bind (empty name) is rejected too.
930        let mut bad = c.clone();
931        bad.replication_bind = Some(ReplicationBind {
932            name: String::new(),
933            ..bind()
934        });
935        assert!(bad.validate().is_err());
936    }
937
938    #[test]
939    fn odata_rejects_non_json_response_format() {
940        let mut c = RestStreamConfig::new("https://x", "");
941        c.odata = Some(ODataConfig {
942            entity: Some("Orders".to_owned()),
943            ..Default::default()
944        });
945        c.response_format = ResponseFormat::Csv;
946        assert!(c.validate().is_err());
947    }
948
949    #[test]
950    fn apply_odata_defaults_renders_all_options_and_v2_link() {
951        let mut c = RestStreamConfig::new("https://host/odata", "");
952        c.odata = Some(ODataConfig {
953            version: ODataVersion::V2,
954            entity: Some("Orders".to_owned()),
955            select: vec!["A".to_owned(), "B".to_owned()],
956            expand: vec!["Lines".to_owned()],
957            filter: Some("A gt 1".to_owned()),
958            orderby: Some("A desc".to_owned()),
959            page_size: Some(250),
960        });
961        c.apply_odata_defaults();
962
963        assert_eq!(c.path, "Orders");
964        assert_eq!(c.records_path.as_deref(), Some("$.value[*]"));
965        assert_eq!(c.query_params.get("$select").unwrap(), "A,B");
966        assert_eq!(c.query_params.get("$expand").unwrap(), "Lines");
967        assert_eq!(c.query_params.get("$filter").unwrap(), "A gt 1");
968        assert_eq!(c.query_params.get("$orderby").unwrap(), "A desc");
969        assert_eq!(
970            c.headers.get("prefer").map(String::as_str),
971            Some("odata.maxpagesize=250")
972        );
973        // v2 uses the un-prefixed next-link key.
974        assert!(matches!(
975            c.pagination,
976            crate::pagination::PaginationStyle::NextLinkInBody { ref next_link_path }
977                if next_link_path == "$['odata.nextLink']"
978        ));
979        // Idempotent: a second application doesn't clobber explicit values.
980        c.apply_odata_defaults();
981        assert_eq!(c.query_params.get("$select").unwrap(), "A,B");
982    }
983
984    #[test]
985    fn headers_serde_round_trip_as_string_map() {
986        let mut c = RestStreamConfig::new("https://x", "/y");
987        c.headers
988            .insert("Prefer".to_owned(), "transient".to_owned());
989        c.headers
990            .insert("Accept".to_owned(), "application/json".to_owned());
991        // Serializes as a plain JSON string map (schema-visible field).
992        let v = serde_json::to_value(&c).unwrap();
993        assert_eq!(v["headers"]["Prefer"], "transient");
994        assert_eq!(v["headers"]["Accept"], "application/json");
995        // And round-trips back into the string map.
996        let back: RestStreamConfig = serde_json::from_value(v).unwrap();
997        assert_eq!(
998            back.headers.get("Prefer").map(String::as_str),
999            Some("transient")
1000        );
1001        assert!(back.validate().is_ok());
1002    }
1003
1004    #[test]
1005    fn validate_rejects_invalid_header_name() {
1006        let mut c = RestStreamConfig::new("https://x", "/y");
1007        c.headers
1008            .insert("Invalid Header".to_owned(), "v".to_owned());
1009        let err = c.validate().unwrap_err();
1010        assert!(
1011            matches!(err, faucet_core::FaucetError::Config(_)),
1012            "expected Config error, got {err:?}"
1013        );
1014        assert!(err.to_string().contains("invalid header name"), "{err}");
1015    }
1016
1017    #[test]
1018    fn validate_rejects_invalid_header_value() {
1019        let mut c = RestStreamConfig::new("https://x", "/y");
1020        // A newline is not a legal header value byte.
1021        c.headers
1022            .insert("X-Bad".to_owned(), "line\nbreak".to_owned());
1023        let err = c.validate().unwrap_err();
1024        assert!(
1025            matches!(err, faucet_core::FaucetError::Config(_)),
1026            "{err:?}"
1027        );
1028    }
1029
1030    #[test]
1031    fn odata_version_next_link_paths() {
1032        assert_eq!(ODataVersion::V4.next_link_path(), "$['@odata.nextLink']");
1033        assert_eq!(ODataVersion::V2.next_link_path(), "$['odata.nextLink']");
1034    }
1035
1036    // ── #548 records_multi validation ───────────────────────────────────────────
1037
1038    fn multi() -> Vec<RecordsMultiSpec> {
1039        vec![
1040            RecordsMultiSpec {
1041                path: "$.added[*]".into(),
1042                op: "upsert".into(),
1043            },
1044            RecordsMultiSpec {
1045                path: "$.removed[*]".into(),
1046                op: "delete".into(),
1047            },
1048        ]
1049    }
1050
1051    #[test]
1052    fn records_multi_ok_and_rejects_conflicts() {
1053        // Bare records_multi validates.
1054        let c = RestStreamConfig::new("https://x", "/y").records_multi(multi());
1055        assert!(c.validate().is_ok());
1056
1057        // records_multi + records_path → error.
1058        let mut both = c.clone();
1059        both.records_path = Some("$.data[*]".into());
1060        assert!(both.validate().is_err());
1061
1062        // records_multi + record_ancestors → error.
1063        let mut anc = c.clone();
1064        anc.record_ancestors = Some(HashMap::from([("x".into(), "y".into())]));
1065        assert!(anc.validate().is_err());
1066
1067        // records_multi with a non-JSON response format → error.
1068        let mut csv = c.clone();
1069        csv.response_format = ResponseFormat::Csv;
1070        assert!(csv.validate().is_err());
1071
1072        // Empty path / op → error.
1073        let mut empty = c.clone();
1074        empty.records_multi[0].path = "  ".into();
1075        assert!(empty.validate().is_err());
1076        let mut empty_op = c.clone();
1077        empty_op.records_multi[0].op = String::new();
1078        assert!(empty_op.validate().is_err());
1079    }
1080
1081    #[test]
1082    fn op_field_requires_records_multi() {
1083        let mut c = RestStreamConfig::new("https://x", "/y");
1084        c.op_field = Some("_op".into());
1085        assert!(c.validate().is_err());
1086
1087        c.records_multi = multi();
1088        assert!(c.validate().is_ok());
1089
1090        c.op_field = Some(" ".into());
1091        assert!(c.validate().is_err());
1092    }
1093
1094    // ── #549 record_ancestors validation ────────────────────────────────────────
1095
1096    #[test]
1097    fn record_ancestors_requires_nested_array_path() {
1098        let anc = HashMap::from([("event_id".to_owned(), "id".to_owned())]);
1099
1100        // No records_path → error.
1101        let mut c = RestStreamConfig::new("https://x", "/y").record_ancestors(anc.clone());
1102        assert!(c.validate().is_err());
1103
1104        // records_path without `[*]` → error.
1105        c.records_path = Some("$.data".into());
1106        assert!(c.validate().is_err());
1107
1108        // Nested array path → ok.
1109        c.records_path = Some("$.data[*].data.object".into());
1110        assert!(c.validate().is_ok());
1111
1112        // Empty dest/rel → error.
1113        let mut bad = c.clone();
1114        bad.record_ancestors = Some(HashMap::from([(String::new(), "id".to_owned())]));
1115        assert!(bad.validate().is_err());
1116    }
1117
1118    // ── #547 persist_cursor validation ──────────────────────────────────────────
1119
1120    #[test]
1121    fn persist_cursor_requires_cursor_pagination() {
1122        // Default pagination (None) → error.
1123        let mut c = RestStreamConfig::new("https://x", "/y").persist_cursor(true);
1124        assert!(c.validate().is_err());
1125
1126        // Cursor → ok.
1127        c.pagination = PaginationStyle::Cursor {
1128            next_token_path: "$.next".into(),
1129            param_name: "cursor".into(),
1130        };
1131        assert!(c.validate().is_ok());
1132
1133        // CursorInBody → ok.
1134        c.pagination = PaginationStyle::CursorInBody {
1135            next_token_path: "$.paging.next".into(),
1136            body_cursor_field: "after".into(),
1137        };
1138        assert!(c.validate().is_ok());
1139    }
1140}