Skip to main content

faucet_source_rest/
async_job.rs

1//! Async-job source pattern (#514): submit → poll → fetch result.
2//!
3//! Covers the "big-data export / bulk / report-run" class of APIs that a
4//! paginated GET can't express (Salesforce Bulk, Stripe Reporting, warehouse
5//! UNLOAD, …). Configured as an `async_job:` block on the REST source; the
6//! fetched result is handed to the `decode:` pipeline (#515) or the normal
7//! body parsing.
8//!
9//! ```yaml
10//! async_job:
11//!   submit: { method: POST, url: "/jobs", json: { query: "SELECT ..." } }
12//!   job_id: "$.id"
13//!   poll:   { url: "/jobs/${job_id}", interval_secs: 5, timeout_secs: 1800 }
14//!   status: { path: "$.state", success: [JobComplete], failure: [Failed, Aborted] }
15//!   fetch:  { url: "/jobs/${job_id}/result" }
16//! decode:
17//!   - parse: { format: csv }
18//! ```
19
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23use std::collections::HashMap;
24
25fn default_get() -> String {
26    "GET".to_owned()
27}
28fn default_interval() -> u64 {
29    5
30}
31fn default_timeout() -> u64 {
32    1800
33}
34
35/// One HTTP request in a job lifecycle (submit / fetch).
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
37#[serde(deny_unknown_fields)]
38pub struct JobRequest {
39    /// HTTP method (default `GET`; set `POST` for `submit`).
40    #[serde(default = "default_get")]
41    pub method: String,
42    /// URL — absolute, or a `base_url`-relative path. `${job_id}` is substituted.
43    ///
44    /// Required for `submit`. For `fetch`, set **exactly one** of `url` (a fixed
45    /// template) or [`url_from`](Self::url_from) (a JSONPath into the poll body).
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub url: Option<String>,
48    /// `fetch` only (#543): resolve the download URL from the **last poll
49    /// response body** via JSONPath, instead of rendering [`url`](Self::url).
50    /// For APIs that return a one-time signed download link in the poll body
51    /// (e.g. a Stripe report run's `result.url`) rather than at a deterministic
52    /// `/{job_id}` path. The matched value must be a string; an absolute URL is
53    /// used verbatim, a relative one is resolved against `base_url`. Mutually
54    /// exclusive with [`url`](Self::url).
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub url_from: Option<String>,
57    /// Extra headers.
58    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
59    pub headers: HashMap<String, String>,
60    /// Extra query params.
61    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
62    pub query: HashMap<String, String>,
63    /// JSON request body.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub json: Option<Value>,
66    /// `fetch` only (#557): result-set continuation. Response header carrying a
67    /// pagination locator (e.g. Salesforce Bulk `Sforce-Locator`). While present
68    /// (and not empty / `"null"`), the fetch is repeated with the locator sent as
69    /// [`locator_param`](Self::locator_param), appending records across pages.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub locator_header: Option<String>,
72    /// `fetch` only (#557): JSONPath into the fetch response **body** for the
73    /// continuation locator, when it rides the body rather than a header.
74    /// Alternative to [`locator_header`](Self::locator_header).
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub locator_body: Option<String>,
77    /// `fetch` only (#557): query-param name the locator is sent as on each
78    /// continuation request. Required when a locator source is configured.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub locator_param: Option<String>,
81    /// `fetch` only (#557): JSONPath for extracting records from each fetch page,
82    /// overriding the source-level `records_path`. Applies to a JSON result body.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub records_path: Option<String>,
85}
86
87/// The poll request + cadence.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct PollSpec {
91    /// HTTP method (default `GET`).
92    #[serde(default = "default_get")]
93    pub method: String,
94    /// Status URL — absolute or `base_url`-relative; `${job_id}` substituted.
95    pub url: String,
96    /// Extra headers.
97    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
98    pub headers: HashMap<String, String>,
99    /// Extra query params.
100    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
101    pub query: HashMap<String, String>,
102    /// Seconds between polls (default `5`).
103    #[serde(default = "default_interval")]
104    pub interval_secs: u64,
105    /// Give up after this many seconds (default `1800`).
106    #[serde(default = "default_timeout")]
107    pub timeout_secs: u64,
108}
109
110/// How to read the job's terminal state from a poll response.
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
112#[serde(deny_unknown_fields)]
113pub struct JobStatus {
114    /// JSONPath to the status value in the poll response.
115    pub path: String,
116    /// Status values meaning "done — go fetch".
117    pub success: Vec<String>,
118    /// Status values meaning "failed — abort".
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub failure: Vec<String>,
121}
122
123/// Terminal classification of a poll status.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum JobOutcome {
126    /// Ready to fetch.
127    Success,
128    /// Failed / aborted.
129    Failure,
130    /// Not terminal yet — keep polling.
131    Pending,
132}
133
134impl JobStatus {
135    /// Classify a poll's status value.
136    pub fn classify(&self, status: &str) -> JobOutcome {
137        if self.success.iter().any(|s| s == status) {
138            JobOutcome::Success
139        } else if self.failure.iter().any(|s| s == status) {
140            JobOutcome::Failure
141        } else {
142            JobOutcome::Pending
143        }
144    }
145}
146
147/// The `async_job:` config block.
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
149#[serde(deny_unknown_fields)]
150pub struct AsyncJobConfig {
151    /// Job-creation request.
152    pub submit: JobRequest,
153    /// JSONPath to the job id in the submit response.
154    pub job_id: String,
155    /// Status polling.
156    pub poll: PollSpec,
157    /// Terminal-state classification.
158    pub status: JobStatus,
159    /// Result-download request.
160    pub fetch: JobRequest,
161}
162
163impl AsyncJobConfig {
164    /// Validate the block at config-load time.
165    pub fn validate(&self) -> Result<(), faucet_core::FaucetError> {
166        // `submit` needs a fixed `url`; `url_from` is meaningless there (no poll
167        // body exists yet).
168        if self.submit.url_from.is_some() {
169            return Err(faucet_core::FaucetError::Config(
170                "async_job: `submit.url_from` is not supported — `submit` needs a fixed `url`"
171                    .into(),
172            ));
173        }
174        if self.submit.url.as_deref().unwrap_or("").trim().is_empty() {
175            return Err(faucet_core::FaucetError::Config(
176                "async_job: `submit.url` must not be empty".into(),
177            ));
178        }
179        // `fetch` needs exactly one of `url` (templated) or `url_from` (JSONPath
180        // into the poll body, #543).
181        let fetch_url = self
182            .fetch
183            .url
184            .as_deref()
185            .map(str::trim)
186            .filter(|s| !s.is_empty());
187        let fetch_url_from = self
188            .fetch
189            .url_from
190            .as_deref()
191            .map(str::trim)
192            .filter(|s| !s.is_empty());
193        match (fetch_url, fetch_url_from) {
194            (Some(_), Some(_)) => {
195                return Err(faucet_core::FaucetError::Config(
196                    "async_job: set exactly one of `fetch.url` or `fetch.url_from`, not both"
197                        .into(),
198                ));
199            }
200            (None, None) => {
201                return Err(faucet_core::FaucetError::Config(
202                    "async_job: `fetch` requires exactly one of `url` (templated) or `url_from` \
203                     (a JSONPath into the poll response body)"
204                        .into(),
205                ));
206            }
207            _ => {}
208        }
209        if self.job_id.trim().is_empty() {
210            return Err(faucet_core::FaucetError::Config(
211                "async_job: `job_id` (JSONPath) must not be empty".into(),
212            ));
213        }
214        if self.status.success.is_empty() {
215            return Err(faucet_core::FaucetError::Config(
216                "async_job: `status.success` must list at least one terminal value".into(),
217            ));
218        }
219        if self.poll.interval_secs == 0 && self.poll.timeout_secs == 0 {
220            return Err(faucet_core::FaucetError::Config(
221                "async_job: `poll.timeout_secs` must be > 0".into(),
222            ));
223        }
224        // #557: result-set continuation (locator paging) is a `fetch`-only
225        // feature and needs a `locator_param` to request the next page.
226        if self.submit.locator_header.is_some()
227            || self.submit.locator_body.is_some()
228            || self.submit.locator_param.is_some()
229            || self.submit.records_path.is_some()
230        {
231            return Err(faucet_core::FaucetError::Config(
232                "async_job: locator/`records_path` fields are `fetch`-only, not valid on `submit`"
233                    .into(),
234            ));
235        }
236        let has_locator_source =
237            self.fetch.locator_header.is_some() || self.fetch.locator_body.is_some();
238        if has_locator_source
239            && self
240                .fetch
241                .locator_param
242                .as_deref()
243                .unwrap_or("")
244                .trim()
245                .is_empty()
246        {
247            return Err(faucet_core::FaucetError::Config(
248                "async_job: `fetch.locator_param` is required when a `locator_header` or \
249                 `locator_body` is configured (it names the query param the locator is sent as)"
250                    .into(),
251            ));
252        }
253        if self.fetch.locator_param.is_some() && !has_locator_source {
254            return Err(faucet_core::FaucetError::Config(
255                "async_job: `fetch.locator_param` needs a `locator_header` or `locator_body` to \
256                 read the locator from"
257                    .into(),
258            ));
259        }
260        Ok(())
261    }
262}
263
264/// Substitute `${job_id}` in a URL/template.
265pub fn substitute_job_id(template: &str, job_id: &str) -> String {
266    template.replace("${job_id}", job_id)
267}
268
269/// Resolve a possibly-relative URL against `base_url`.
270pub fn resolve_url(base_url: &str, url: &str) -> String {
271    if url.starts_with("http://") || url.starts_with("https://") {
272        url.to_string()
273    } else {
274        format!(
275            "{}/{}",
276            base_url.trim_end_matches('/'),
277            url.trim_start_matches('/')
278        )
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use serde_json::json;
286
287    #[test]
288    fn classify_maps_status_to_outcome() {
289        let s = JobStatus {
290            path: "$.state".into(),
291            success: vec!["JobComplete".into()],
292            failure: vec!["Failed".into(), "Aborted".into()],
293        };
294        assert_eq!(s.classify("JobComplete"), JobOutcome::Success);
295        assert_eq!(s.classify("Failed"), JobOutcome::Failure);
296        assert_eq!(s.classify("Aborted"), JobOutcome::Failure);
297        assert_eq!(s.classify("InProgress"), JobOutcome::Pending);
298    }
299
300    #[test]
301    fn substitute_and_resolve_urls() {
302        assert_eq!(substitute_job_id("/jobs/${job_id}/x", "42"), "/jobs/42/x");
303        assert_eq!(resolve_url("https://h", "/jobs"), "https://h/jobs");
304        assert_eq!(resolve_url("https://h/", "jobs"), "https://h/jobs");
305        assert_eq!(
306            resolve_url("https://h", "https://other/x"),
307            "https://other/x"
308        );
309    }
310
311    #[test]
312    fn validate_rejects_empty_and_no_success() {
313        let base: AsyncJobConfig = serde_json::from_value(json!({
314            "submit": { "url": "/jobs" },
315            "job_id": "$.id",
316            "poll": { "url": "/jobs/${job_id}" },
317            "status": { "path": "$.state", "success": ["Done"] },
318            "fetch": { "url": "/jobs/${job_id}/result", "method": "GET" }
319        }))
320        .unwrap();
321        assert!(base.validate().is_ok());
322
323        let mut no_success = base.clone();
324        no_success.status.success.clear();
325        assert!(no_success.validate().is_err());
326
327        let mut empty_id = base.clone();
328        empty_id.job_id = " ".into();
329        assert!(empty_id.validate().is_err());
330    }
331
332    #[test]
333    fn validate_fetch_url_xor_url_from() {
334        let make = |fetch: Value| -> AsyncJobConfig {
335            serde_json::from_value(json!({
336                "submit": { "method": "POST", "url": "/jobs" },
337                "job_id": "$.id",
338                "poll": { "url": "/jobs/${job_id}" },
339                "status": { "path": "$.state", "success": ["Done"] },
340                "fetch": fetch
341            }))
342            .unwrap()
343        };
344
345        // Exactly one → ok.
346        assert!(
347            make(json!({ "url": "/jobs/${job_id}/result" }))
348                .validate()
349                .is_ok()
350        );
351        assert!(
352            make(json!({ "url_from": "$.result.url" }))
353                .validate()
354                .is_ok()
355        );
356
357        // Both → error.
358        let both = make(json!({ "url": "/r", "url_from": "$.result.url" }));
359        let err = both.validate().unwrap_err();
360        assert!(
361            matches!(err, faucet_core::FaucetError::Config(_)),
362            "{err:?}"
363        );
364        assert!(err.to_string().contains("exactly one"), "{err}");
365
366        // Neither → error.
367        let neither = make(json!({}));
368        assert!(neither.validate().is_err());
369
370        // Empty strings count as unset → neither → error.
371        let empty = make(json!({ "url": "  " }));
372        assert!(empty.validate().is_err());
373    }
374
375    #[test]
376    fn validate_rejects_url_from_on_submit() {
377        let cfg: AsyncJobConfig = serde_json::from_value(json!({
378            "submit": { "method": "POST", "url": "/jobs", "url_from": "$.x" },
379            "job_id": "$.id",
380            "poll": { "url": "/jobs/${job_id}" },
381            "status": { "path": "$.state", "success": ["Done"] },
382            "fetch": { "url_from": "$.result.url" }
383        }))
384        .unwrap();
385        let err = cfg.validate().unwrap_err();
386        assert!(err.to_string().contains("submit.url_from"), "{err}");
387    }
388
389    #[test]
390    fn validate_locator_continuation_fields() {
391        let make = |fetch: Value| -> AsyncJobConfig {
392            serde_json::from_value(json!({
393                "submit": { "method": "POST", "url": "/jobs" },
394                "job_id": "$.id",
395                "poll": { "url": "/jobs/${job_id}" },
396                "status": { "path": "$.state", "success": ["Done"] },
397                "fetch": fetch
398            }))
399            .unwrap()
400        };
401
402        // Header locator + param → ok.
403        assert!(
404            make(json!({
405                "url": "/jobs/${job_id}/results",
406                "locator_header": "Sforce-Locator",
407                "locator_param": "locator",
408                "records_path": "$.records[*]"
409            }))
410            .validate()
411            .is_ok()
412        );
413        // Body locator + param → ok.
414        assert!(
415            make(json!({
416                "url_from": "$.result.url",
417                "locator_body": "$.next_locator",
418                "locator_param": "locator"
419            }))
420            .validate()
421            .is_ok()
422        );
423        // Locator source without param → error.
424        let err = make(json!({
425            "url": "/r",
426            "locator_header": "Sforce-Locator"
427        }))
428        .validate()
429        .unwrap_err();
430        assert!(err.to_string().contains("locator_param"), "{err}");
431        // Param without a source → error.
432        assert!(
433            make(json!({ "url": "/r", "locator_param": "locator" }))
434                .validate()
435                .is_err()
436        );
437    }
438
439    #[test]
440    fn validate_rejects_locator_on_submit() {
441        let cfg: AsyncJobConfig = serde_json::from_value(json!({
442            "submit": { "method": "POST", "url": "/jobs", "locator_header": "X" },
443            "job_id": "$.id",
444            "poll": { "url": "/jobs/${job_id}" },
445            "status": { "path": "$.state", "success": ["Done"] },
446            "fetch": { "url": "/r" }
447        }))
448        .unwrap();
449        assert!(cfg.validate().is_err());
450    }
451
452    #[test]
453    fn poll_defaults_apply() {
454        let cfg: AsyncJobConfig = serde_json::from_value(json!({
455            "submit": { "url": "/jobs" },
456            "job_id": "$.id",
457            "poll": { "url": "/jobs/${job_id}" },
458            "status": { "path": "$.state", "success": ["Done"] },
459            "fetch": { "url": "/r" }
460        }))
461        .unwrap();
462        assert_eq!(cfg.poll.interval_secs, 5);
463        assert_eq!(cfg.poll.timeout_secs, 1800);
464        assert_eq!(cfg.poll.method, "GET");
465        assert_eq!(cfg.submit.method, "GET"); // default; examples set POST explicitly
466        assert_eq!(cfg.fetch.method, "GET");
467    }
468}