1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
37#[serde(deny_unknown_fields)]
38pub struct JobRequest {
39 #[serde(default = "default_get")]
41 pub method: String,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub url: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub url_from: Option<String>,
57 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
59 pub headers: HashMap<String, String>,
60 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
62 pub query: HashMap<String, String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub json: Option<Value>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub locator_header: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub locator_body: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub locator_param: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub records_path: Option<String>,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct PollSpec {
91 #[serde(default = "default_get")]
93 pub method: String,
94 pub url: String,
96 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
98 pub headers: HashMap<String, String>,
99 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
101 pub query: HashMap<String, String>,
102 #[serde(default = "default_interval")]
104 pub interval_secs: u64,
105 #[serde(default = "default_timeout")]
107 pub timeout_secs: u64,
108}
109
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
112#[serde(deny_unknown_fields)]
113pub struct JobStatus {
114 pub path: String,
116 pub success: Vec<String>,
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub failure: Vec<String>,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum JobOutcome {
126 Success,
128 Failure,
130 Pending,
132}
133
134impl JobStatus {
135 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
149#[serde(deny_unknown_fields)]
150pub struct AsyncJobConfig {
151 pub submit: JobRequest,
153 pub job_id: String,
155 pub poll: PollSpec,
157 pub status: JobStatus,
159 pub fetch: JobRequest,
161}
162
163impl AsyncJobConfig {
164 pub fn validate(&self) -> Result<(), faucet_core::FaucetError> {
166 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 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 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
264pub fn substitute_job_id(template: &str, job_id: &str) -> String {
266 template.replace("${job_id}", job_id)
267}
268
269pub 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 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 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 let neither = make(json!({}));
368 assert!(neither.validate().is_err());
369
370 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 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 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 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 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"); assert_eq!(cfg.fetch.method, "GET");
467 }
468}