use crate::config::PipelineConfig;
use crate::expand::{ExpandedNode, expand};
use crate::serve::error::ServeError;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ConfigFormat {
#[default]
Yaml,
Json,
}
#[derive(Debug)]
pub struct LoadedSubmission {
pub cfg: PipelineConfig,
pub nodes: Vec<ExpandedNode>,
}
pub async fn load_submission(
body: &str,
format: ConfigFormat,
default_base: Option<&Value>,
) -> Result<LoadedSubmission, ServeError> {
let mut submitted: Value = match format {
ConfigFormat::Yaml => serde_yaml::from_str(body)
.map_err(|e| ServeError::BadConfig(format!("invalid YAML: {e}")))?,
ConfigFormat::Json => serde_json::from_str(body)
.map_err(|e| ServeError::BadConfig(format!("invalid JSON: {e}")))?,
};
crate::interpolate::interpolate_value(&mut submitted)
.map_err(|e| ServeError::BadConfig(e.to_string()))?;
let mut merged = match default_base {
Some(base) => {
let mut m = base.clone();
crate::merge::merge_value(&mut m, submitted);
m
}
None => submitted,
};
crate::params::bind_document(
&mut merged,
&Default::default(),
crate::params::BindMode::Strict,
)
.map_err(|e| ServeError::Unprocessable {
message: e.to_string(),
details: None,
})?;
let mut cfg = PipelineConfig::from_value(merged).map_err(|e| ServeError::Unprocessable {
message: e.to_string(),
details: None,
})?;
#[cfg(feature = "schedule")]
if cfg.schedule.is_some() {
return Err(ServeError::BadConfig(
"submitted config contains a `schedule:` block — serve runs once per \
submission; use `faucet schedule` for cron scheduling"
.into(),
));
}
crate::secrets::resolve_secrets(&mut cfg)
.await
.map_err(|e| ServeError::BadConfig(e.to_string()))?;
let nodes = expand(&cfg).map_err(|e| ServeError::Unprocessable {
message: e.to_string(),
details: None,
})?;
Ok(LoadedSubmission { cfg, nodes })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn base() -> Value {
json!({
"version": 1,
"pipeline": {
"source": { "type": "csv", "config": { "path": "DEFAULT.csv" } },
"sink": { "type": "jsonl", "config": { "path": "out.jsonl" } }
}
})
}
#[tokio::test]
async fn submitted_overrides_default() {
let body = r#"{ "pipeline": { "source": { "config": { "path": "OVERRIDE.csv" } } } }"#;
let loaded = load_submission(body, ConfigFormat::Json, Some(&base()))
.await
.unwrap();
let node = &loaded.nodes[0];
assert_eq!(node.source.config["path"], "OVERRIDE.csv");
assert_eq!(node.sink.config["path"], "out.jsonl");
}
#[tokio::test]
async fn missing_version_without_base_is_unprocessable() {
let body = r#"{ "pipeline": {} }"#;
let err = load_submission(body, ConfigFormat::Json, None)
.await
.unwrap_err();
assert!(matches!(
err,
ServeError::Unprocessable { .. } | ServeError::BadConfig(_)
));
}
#[cfg(feature = "schedule")]
#[tokio::test]
async fn schedule_block_is_rejected() {
let body = r#"
version: 1
pipeline:
source: { type: csv, config: { path: x.csv } }
sink: { type: jsonl, config: { path: out.jsonl } }
schedule:
cron: "0 * * * *"
timezone: UTC
"#;
let err = load_submission(body, ConfigFormat::Yaml, None)
.await
.unwrap_err();
match err {
ServeError::BadConfig(m) => assert!(m.contains("schedule:")),
other => panic!("expected BadConfig, got {other:?}"),
}
}
#[tokio::test]
async fn invalid_yaml_is_bad_config() {
let err = load_submission("{[bad", ConfigFormat::Yaml, None)
.await
.unwrap_err();
assert!(matches!(err, ServeError::BadConfig(_)));
}
#[tokio::test]
async fn submitted_extends_is_rejected_with_composition_hint() {
let body = "version: 1\nextends: /etc/passwd\npipeline:\n source: { type: csv, config: { path: x.csv } }\n sink: { type: jsonl, config: { path: o.jsonl } }\n";
let err = load_submission(body, ConfigFormat::Yaml, None)
.await
.unwrap_err();
let msg = match &err {
ServeError::Unprocessable { message, .. } => message.clone(),
ServeError::BadConfig(m) => m.clone(),
other => format!("{other:?}"),
};
assert!(
msg.contains("composition"),
"submitted extends must be rejected with the composition hint, got: {msg}"
);
}
}