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}