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 mut 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 crate::params::bind_document(
66 &mut merged,
67 &Default::default(),
68 crate::params::BindMode::Strict,
69 )
70 .map_err(|e| ServeError::Unprocessable {
71 message: e.to_string(),
72 details: None,
73 })?;
74
75 let mut cfg = PipelineConfig::from_value(merged).map_err(|e| ServeError::Unprocessable {
77 message: e.to_string(),
78 details: None,
79 })?;
80
81 #[cfg(feature = "schedule")]
83 if cfg.schedule.is_some() {
84 return Err(ServeError::BadConfig(
85 "submitted config contains a `schedule:` block — serve runs once per \
86 submission; use `faucet schedule` for cron scheduling"
87 .into(),
88 ));
89 }
90
91 crate::secrets::resolve_secrets(&mut cfg)
93 .await
94 .map_err(|e| ServeError::BadConfig(e.to_string()))?;
95
96 let nodes = expand(&cfg).map_err(|e| ServeError::Unprocessable {
98 message: e.to_string(),
99 details: None,
100 })?;
101
102 Ok(LoadedSubmission { cfg, nodes })
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use serde_json::json;
109
110 fn base() -> Value {
111 json!({
112 "version": 1,
113 "pipeline": {
114 "source": { "type": "csv", "config": { "path": "DEFAULT.csv" } },
115 "sink": { "type": "jsonl", "config": { "path": "out.jsonl" } }
116 }
117 })
118 }
119
120 #[tokio::test]
121 async fn submitted_overrides_default() {
122 let body = r#"{ "pipeline": { "source": { "config": { "path": "OVERRIDE.csv" } } } }"#;
123 let loaded = load_submission(body, ConfigFormat::Json, Some(&base()))
124 .await
125 .unwrap();
126 let node = &loaded.nodes[0];
128 assert_eq!(node.source.config["path"], "OVERRIDE.csv");
129 assert_eq!(node.sink.config["path"], "out.jsonl");
130 }
131
132 #[tokio::test]
133 async fn missing_version_without_base_is_unprocessable() {
134 let body = r#"{ "pipeline": {} }"#;
137 let err = load_submission(body, ConfigFormat::Json, None)
138 .await
139 .unwrap_err();
140 assert!(matches!(
141 err,
142 ServeError::Unprocessable { .. } | ServeError::BadConfig(_)
143 ));
144 }
145
146 #[cfg(feature = "schedule")]
147 #[tokio::test]
148 async fn schedule_block_is_rejected() {
149 let body = r#"
150version: 1
151pipeline:
152 source: { type: csv, config: { path: x.csv } }
153 sink: { type: jsonl, config: { path: out.jsonl } }
154schedule:
155 cron: "0 * * * *"
156 timezone: UTC
157"#;
158 let err = load_submission(body, ConfigFormat::Yaml, None)
159 .await
160 .unwrap_err();
161 match err {
162 ServeError::BadConfig(m) => assert!(m.contains("schedule:")),
163 other => panic!("expected BadConfig, got {other:?}"),
164 }
165 }
166
167 #[tokio::test]
168 async fn invalid_yaml_is_bad_config() {
169 let err = load_submission("{[bad", ConfigFormat::Yaml, None)
170 .await
171 .unwrap_err();
172 assert!(matches!(err, ServeError::BadConfig(_)));
173 }
174
175 #[tokio::test]
176 async fn submitted_extends_is_rejected_with_composition_hint() {
177 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";
182 let err = load_submission(body, ConfigFormat::Yaml, None)
183 .await
184 .unwrap_err();
185 let msg = match &err {
187 ServeError::Unprocessable { message, .. } => message.clone(),
188 ServeError::BadConfig(m) => m.clone(),
189 other => format!("{other:?}"),
190 };
191 assert!(
194 msg.contains("composition"),
195 "submitted extends must be rejected with the composition hint, got: {msg}"
196 );
197 }
198}