1use crate::config::PipelineConfig;
8use crate::expand::{ExpandedNode, expand};
9use crate::serve::error::ServeError;
10use serde_json::Value;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum ConfigFormat {
15 #[default]
16 Yaml,
17 Json,
18}
19
20#[derive(Debug)]
22pub struct LoadedSubmission {
23 pub cfg: PipelineConfig,
24 pub nodes: Vec<ExpandedNode>,
25}
26
27pub async fn load_submission(
29 body: &str,
30 format: ConfigFormat,
31 default_base: Option<&Value>,
32) -> Result<LoadedSubmission, ServeError> {
33 let interpolated =
35 crate::interpolate::interpolate(body).map_err(|e| ServeError::BadConfig(e.to_string()))?;
36
37 let submitted: Value = match format {
39 ConfigFormat::Yaml => serde_yaml::from_str(&interpolated)
40 .map_err(|e| ServeError::BadConfig(format!("invalid YAML: {e}")))?,
41 ConfigFormat::Json => serde_json::from_str(&interpolated)
42 .map_err(|e| ServeError::BadConfig(format!("invalid JSON: {e}")))?,
43 };
44
45 let merged = match default_base {
47 Some(base) => {
48 let mut m = base.clone();
49 crate::merge::merge_value(&mut m, submitted);
50 m
51 }
52 None => submitted,
53 };
54
55 let mut cfg = PipelineConfig::from_value(merged).map_err(|e| ServeError::Unprocessable {
57 message: e.to_string(),
58 details: None,
59 })?;
60
61 #[cfg(feature = "schedule")]
63 if cfg.schedule.is_some() {
64 return Err(ServeError::BadConfig(
65 "submitted config contains a `schedule:` block — serve runs once per \
66 submission; use `faucet schedule` for cron scheduling"
67 .into(),
68 ));
69 }
70
71 crate::secrets::resolve_secrets(&mut cfg)
73 .await
74 .map_err(|e| ServeError::BadConfig(e.to_string()))?;
75
76 let nodes = expand(&cfg).map_err(|e| ServeError::Unprocessable {
78 message: e.to_string(),
79 details: None,
80 })?;
81
82 Ok(LoadedSubmission { cfg, nodes })
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88 use serde_json::json;
89
90 fn base() -> Value {
91 json!({
92 "version": 1,
93 "pipeline": {
94 "source": { "type": "csv", "config": { "path": "DEFAULT.csv" } },
95 "sink": { "type": "jsonl", "config": { "path": "out.jsonl" } }
96 }
97 })
98 }
99
100 #[tokio::test]
101 async fn submitted_overrides_default() {
102 let body = r#"{ "pipeline": { "source": { "config": { "path": "OVERRIDE.csv" } } } }"#;
103 let loaded = load_submission(body, ConfigFormat::Json, Some(&base()))
104 .await
105 .unwrap();
106 let node = &loaded.nodes[0];
108 assert_eq!(node.source.config["path"], "OVERRIDE.csv");
109 assert_eq!(node.sink.config["path"], "out.jsonl");
110 }
111
112 #[tokio::test]
113 async fn missing_version_without_base_is_unprocessable() {
114 let body = r#"{ "pipeline": {} }"#;
117 let err = load_submission(body, ConfigFormat::Json, None)
118 .await
119 .unwrap_err();
120 assert!(matches!(
121 err,
122 ServeError::Unprocessable { .. } | ServeError::BadConfig(_)
123 ));
124 }
125
126 #[cfg(feature = "schedule")]
127 #[tokio::test]
128 async fn schedule_block_is_rejected() {
129 let body = r#"
130version: 1
131pipeline:
132 source: { type: csv, config: { path: x.csv } }
133 sink: { type: jsonl, config: { path: out.jsonl } }
134schedule:
135 cron: "0 * * * *"
136 timezone: UTC
137"#;
138 let err = load_submission(body, ConfigFormat::Yaml, None)
139 .await
140 .unwrap_err();
141 match err {
142 ServeError::BadConfig(m) => assert!(m.contains("schedule:")),
143 other => panic!("expected BadConfig, got {other:?}"),
144 }
145 }
146
147 #[tokio::test]
148 async fn invalid_yaml_is_bad_config() {
149 let err = load_submission("{[bad", ConfigFormat::Yaml, None)
150 .await
151 .unwrap_err();
152 assert!(matches!(err, ServeError::BadConfig(_)));
153 }
154}