use crate::config::PipelineConfig;
use crate::expand::{ExpandedNode, expand};
use crate::serve::error::ServeError;
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
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 interpolated =
crate::interpolate::interpolate(body).map_err(|e| ServeError::BadConfig(e.to_string()))?;
let submitted: Value = match format {
ConfigFormat::Yaml => serde_yaml::from_str(&interpolated)
.map_err(|e| ServeError::BadConfig(format!("invalid YAML: {e}")))?,
ConfigFormat::Json => serde_json::from_str(&interpolated)
.map_err(|e| ServeError::BadConfig(format!("invalid JSON: {e}")))?,
};
let merged = match default_base {
Some(base) => {
let mut m = base.clone();
crate::merge::merge_value(&mut m, submitted);
m
}
None => submitted,
};
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(_)));
}
}