1use crate::config::PipelineConfig;
8use crate::expand::{ExpandedNode, expand};
9use crate::serve::error::ServeError;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum ConfigFormat {
17 #[default]
18 Yaml,
19 Json,
20}
21
22#[derive(Debug)]
24pub struct LoadedSubmission {
25 pub cfg: PipelineConfig,
26 pub nodes: Vec<ExpandedNode>,
27}
28
29pub async fn load_submission(
31 body: &str,
32 format: ConfigFormat,
33 default_base: Option<&Value>,
34) -> Result<LoadedSubmission, ServeError> {
35 let mut submitted: Value = match format {
37 ConfigFormat::Yaml => serde_yaml::from_str(body)
38 .map_err(|e| ServeError::BadConfig(format!("invalid YAML: {e}")))?,
39 ConfigFormat::Json => serde_json::from_str(body)
40 .map_err(|e| ServeError::BadConfig(format!("invalid JSON: {e}")))?,
41 };
42
43 crate::interpolate::interpolate_value(&mut submitted)
47 .map_err(|e| ServeError::BadConfig(e.to_string()))?;
48
49 let merged = match default_base {
51 Some(base) => {
52 let mut m = base.clone();
53 crate::merge::merge_value(&mut m, submitted);
54 m
55 }
56 None => submitted,
57 };
58
59 let mut cfg = PipelineConfig::from_value(merged).map_err(|e| ServeError::Unprocessable {
61 message: e.to_string(),
62 details: None,
63 })?;
64
65 #[cfg(feature = "schedule")]
67 if cfg.schedule.is_some() {
68 return Err(ServeError::BadConfig(
69 "submitted config contains a `schedule:` block — serve runs once per \
70 submission; use `faucet schedule` for cron scheduling"
71 .into(),
72 ));
73 }
74
75 crate::secrets::resolve_secrets(&mut cfg)
77 .await
78 .map_err(|e| ServeError::BadConfig(e.to_string()))?;
79
80 let nodes = expand(&cfg).map_err(|e| ServeError::Unprocessable {
82 message: e.to_string(),
83 details: None,
84 })?;
85
86 Ok(LoadedSubmission { cfg, nodes })
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use serde_json::json;
93
94 fn base() -> Value {
95 json!({
96 "version": 1,
97 "pipeline": {
98 "source": { "type": "csv", "config": { "path": "DEFAULT.csv" } },
99 "sink": { "type": "jsonl", "config": { "path": "out.jsonl" } }
100 }
101 })
102 }
103
104 #[tokio::test]
105 async fn submitted_overrides_default() {
106 let body = r#"{ "pipeline": { "source": { "config": { "path": "OVERRIDE.csv" } } } }"#;
107 let loaded = load_submission(body, ConfigFormat::Json, Some(&base()))
108 .await
109 .unwrap();
110 let node = &loaded.nodes[0];
112 assert_eq!(node.source.config["path"], "OVERRIDE.csv");
113 assert_eq!(node.sink.config["path"], "out.jsonl");
114 }
115
116 #[tokio::test]
117 async fn missing_version_without_base_is_unprocessable() {
118 let body = r#"{ "pipeline": {} }"#;
121 let err = load_submission(body, ConfigFormat::Json, None)
122 .await
123 .unwrap_err();
124 assert!(matches!(
125 err,
126 ServeError::Unprocessable { .. } | ServeError::BadConfig(_)
127 ));
128 }
129
130 #[cfg(feature = "schedule")]
131 #[tokio::test]
132 async fn schedule_block_is_rejected() {
133 let body = r#"
134version: 1
135pipeline:
136 source: { type: csv, config: { path: x.csv } }
137 sink: { type: jsonl, config: { path: out.jsonl } }
138schedule:
139 cron: "0 * * * *"
140 timezone: UTC
141"#;
142 let err = load_submission(body, ConfigFormat::Yaml, None)
143 .await
144 .unwrap_err();
145 match err {
146 ServeError::BadConfig(m) => assert!(m.contains("schedule:")),
147 other => panic!("expected BadConfig, got {other:?}"),
148 }
149 }
150
151 #[tokio::test]
152 async fn invalid_yaml_is_bad_config() {
153 let err = load_submission("{[bad", ConfigFormat::Yaml, None)
154 .await
155 .unwrap_err();
156 assert!(matches!(err, ServeError::BadConfig(_)));
157 }
158
159 #[tokio::test]
160 async fn submitted_extends_is_rejected_with_composition_hint() {
161 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";
166 let err = load_submission(body, ConfigFormat::Yaml, None)
167 .await
168 .unwrap_err();
169 let msg = match &err {
171 ServeError::Unprocessable { message, .. } => message.clone(),
172 ServeError::BadConfig(m) => m.clone(),
173 other => format!("{other:?}"),
174 };
175 assert!(
178 msg.contains("composition"),
179 "submitted extends must be rejected with the composition hint, got: {msg}"
180 );
181 }
182}