use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
fn default_get() -> String {
"GET".to_owned()
}
fn default_interval() -> u64 {
5
}
fn default_timeout() -> u64 {
1800
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct JobRequest {
#[serde(default = "default_get")]
pub method: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url_from: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub query: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub json: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locator_header: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locator_body: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub locator_param: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub records_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PollSpec {
#[serde(default = "default_get")]
pub method: String,
pub url: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub query: HashMap<String, String>,
#[serde(default = "default_interval")]
pub interval_secs: u64,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct JobStatus {
pub path: String,
pub success: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub failure: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobOutcome {
Success,
Failure,
Pending,
}
impl JobStatus {
pub fn classify(&self, status: &str) -> JobOutcome {
if self.success.iter().any(|s| s == status) {
JobOutcome::Success
} else if self.failure.iter().any(|s| s == status) {
JobOutcome::Failure
} else {
JobOutcome::Pending
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AsyncJobConfig {
pub submit: JobRequest,
pub job_id: String,
pub poll: PollSpec,
pub status: JobStatus,
pub fetch: JobRequest,
}
impl AsyncJobConfig {
pub fn validate(&self) -> Result<(), faucet_core::FaucetError> {
if self.submit.url_from.is_some() {
return Err(faucet_core::FaucetError::Config(
"async_job: `submit.url_from` is not supported — `submit` needs a fixed `url`"
.into(),
));
}
if self.submit.url.as_deref().unwrap_or("").trim().is_empty() {
return Err(faucet_core::FaucetError::Config(
"async_job: `submit.url` must not be empty".into(),
));
}
let fetch_url = self
.fetch
.url
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let fetch_url_from = self
.fetch
.url_from
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
match (fetch_url, fetch_url_from) {
(Some(_), Some(_)) => {
return Err(faucet_core::FaucetError::Config(
"async_job: set exactly one of `fetch.url` or `fetch.url_from`, not both"
.into(),
));
}
(None, None) => {
return Err(faucet_core::FaucetError::Config(
"async_job: `fetch` requires exactly one of `url` (templated) or `url_from` \
(a JSONPath into the poll response body)"
.into(),
));
}
_ => {}
}
if self.job_id.trim().is_empty() {
return Err(faucet_core::FaucetError::Config(
"async_job: `job_id` (JSONPath) must not be empty".into(),
));
}
if self.status.success.is_empty() {
return Err(faucet_core::FaucetError::Config(
"async_job: `status.success` must list at least one terminal value".into(),
));
}
if self.poll.interval_secs == 0 && self.poll.timeout_secs == 0 {
return Err(faucet_core::FaucetError::Config(
"async_job: `poll.timeout_secs` must be > 0".into(),
));
}
if self.submit.locator_header.is_some()
|| self.submit.locator_body.is_some()
|| self.submit.locator_param.is_some()
|| self.submit.records_path.is_some()
{
return Err(faucet_core::FaucetError::Config(
"async_job: locator/`records_path` fields are `fetch`-only, not valid on `submit`"
.into(),
));
}
let has_locator_source =
self.fetch.locator_header.is_some() || self.fetch.locator_body.is_some();
if has_locator_source
&& self
.fetch
.locator_param
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
return Err(faucet_core::FaucetError::Config(
"async_job: `fetch.locator_param` is required when a `locator_header` or \
`locator_body` is configured (it names the query param the locator is sent as)"
.into(),
));
}
if self.fetch.locator_param.is_some() && !has_locator_source {
return Err(faucet_core::FaucetError::Config(
"async_job: `fetch.locator_param` needs a `locator_header` or `locator_body` to \
read the locator from"
.into(),
));
}
Ok(())
}
}
pub fn substitute_job_id(template: &str, job_id: &str) -> String {
template.replace("${job_id}", job_id)
}
pub fn resolve_url(base_url: &str, url: &str) -> String {
if url.starts_with("http://") || url.starts_with("https://") {
url.to_string()
} else {
format!(
"{}/{}",
base_url.trim_end_matches('/'),
url.trim_start_matches('/')
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn classify_maps_status_to_outcome() {
let s = JobStatus {
path: "$.state".into(),
success: vec!["JobComplete".into()],
failure: vec!["Failed".into(), "Aborted".into()],
};
assert_eq!(s.classify("JobComplete"), JobOutcome::Success);
assert_eq!(s.classify("Failed"), JobOutcome::Failure);
assert_eq!(s.classify("Aborted"), JobOutcome::Failure);
assert_eq!(s.classify("InProgress"), JobOutcome::Pending);
}
#[test]
fn substitute_and_resolve_urls() {
assert_eq!(substitute_job_id("/jobs/${job_id}/x", "42"), "/jobs/42/x");
assert_eq!(resolve_url("https://h", "/jobs"), "https://h/jobs");
assert_eq!(resolve_url("https://h/", "jobs"), "https://h/jobs");
assert_eq!(
resolve_url("https://h", "https://other/x"),
"https://other/x"
);
}
#[test]
fn validate_rejects_empty_and_no_success() {
let base: AsyncJobConfig = serde_json::from_value(json!({
"submit": { "url": "/jobs" },
"job_id": "$.id",
"poll": { "url": "/jobs/${job_id}" },
"status": { "path": "$.state", "success": ["Done"] },
"fetch": { "url": "/jobs/${job_id}/result", "method": "GET" }
}))
.unwrap();
assert!(base.validate().is_ok());
let mut no_success = base.clone();
no_success.status.success.clear();
assert!(no_success.validate().is_err());
let mut empty_id = base.clone();
empty_id.job_id = " ".into();
assert!(empty_id.validate().is_err());
}
#[test]
fn validate_fetch_url_xor_url_from() {
let make = |fetch: Value| -> AsyncJobConfig {
serde_json::from_value(json!({
"submit": { "method": "POST", "url": "/jobs" },
"job_id": "$.id",
"poll": { "url": "/jobs/${job_id}" },
"status": { "path": "$.state", "success": ["Done"] },
"fetch": fetch
}))
.unwrap()
};
assert!(
make(json!({ "url": "/jobs/${job_id}/result" }))
.validate()
.is_ok()
);
assert!(
make(json!({ "url_from": "$.result.url" }))
.validate()
.is_ok()
);
let both = make(json!({ "url": "/r", "url_from": "$.result.url" }));
let err = both.validate().unwrap_err();
assert!(
matches!(err, faucet_core::FaucetError::Config(_)),
"{err:?}"
);
assert!(err.to_string().contains("exactly one"), "{err}");
let neither = make(json!({}));
assert!(neither.validate().is_err());
let empty = make(json!({ "url": " " }));
assert!(empty.validate().is_err());
}
#[test]
fn validate_rejects_url_from_on_submit() {
let cfg: AsyncJobConfig = serde_json::from_value(json!({
"submit": { "method": "POST", "url": "/jobs", "url_from": "$.x" },
"job_id": "$.id",
"poll": { "url": "/jobs/${job_id}" },
"status": { "path": "$.state", "success": ["Done"] },
"fetch": { "url_from": "$.result.url" }
}))
.unwrap();
let err = cfg.validate().unwrap_err();
assert!(err.to_string().contains("submit.url_from"), "{err}");
}
#[test]
fn validate_locator_continuation_fields() {
let make = |fetch: Value| -> AsyncJobConfig {
serde_json::from_value(json!({
"submit": { "method": "POST", "url": "/jobs" },
"job_id": "$.id",
"poll": { "url": "/jobs/${job_id}" },
"status": { "path": "$.state", "success": ["Done"] },
"fetch": fetch
}))
.unwrap()
};
assert!(
make(json!({
"url": "/jobs/${job_id}/results",
"locator_header": "Sforce-Locator",
"locator_param": "locator",
"records_path": "$.records[*]"
}))
.validate()
.is_ok()
);
assert!(
make(json!({
"url_from": "$.result.url",
"locator_body": "$.next_locator",
"locator_param": "locator"
}))
.validate()
.is_ok()
);
let err = make(json!({
"url": "/r",
"locator_header": "Sforce-Locator"
}))
.validate()
.unwrap_err();
assert!(err.to_string().contains("locator_param"), "{err}");
assert!(
make(json!({ "url": "/r", "locator_param": "locator" }))
.validate()
.is_err()
);
}
#[test]
fn validate_rejects_locator_on_submit() {
let cfg: AsyncJobConfig = serde_json::from_value(json!({
"submit": { "method": "POST", "url": "/jobs", "locator_header": "X" },
"job_id": "$.id",
"poll": { "url": "/jobs/${job_id}" },
"status": { "path": "$.state", "success": ["Done"] },
"fetch": { "url": "/r" }
}))
.unwrap();
assert!(cfg.validate().is_err());
}
#[test]
fn poll_defaults_apply() {
let cfg: AsyncJobConfig = serde_json::from_value(json!({
"submit": { "url": "/jobs" },
"job_id": "$.id",
"poll": { "url": "/jobs/${job_id}" },
"status": { "path": "$.state", "success": ["Done"] },
"fetch": { "url": "/r" }
}))
.unwrap();
assert_eq!(cfg.poll.interval_secs, 5);
assert_eq!(cfg.poll.timeout_secs, 1800);
assert_eq!(cfg.poll.method, "GET");
assert_eq!(cfg.submit.method, "GET"); assert_eq!(cfg.fetch.method, "GET");
}
}