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 interpolated =
37 crate::interpolate::interpolate(body).map_err(|e| ServeError::BadConfig(e.to_string()))?;
38
39 let submitted: Value = match format {
41 ConfigFormat::Yaml => serde_yaml::from_str(&interpolated)
42 .map_err(|e| ServeError::BadConfig(format!("invalid YAML: {e}")))?,
43 ConfigFormat::Json => serde_json::from_str(&interpolated)
44 .map_err(|e| ServeError::BadConfig(format!("invalid JSON: {e}")))?,
45 };
46
47 let merged = match default_base {
49 Some(base) => {
50 let mut m = base.clone();
51 crate::merge::merge_value(&mut m, submitted);
52 m
53 }
54 None => submitted,
55 };
56
57 let mut cfg = PipelineConfig::from_value(merged).map_err(|e| ServeError::Unprocessable {
59 message: e.to_string(),
60 details: None,
61 })?;
62
63 #[cfg(feature = "schedule")]
65 if cfg.schedule.is_some() {
66 return Err(ServeError::BadConfig(
67 "submitted config contains a `schedule:` block — serve runs once per \
68 submission; use `faucet schedule` for cron scheduling"
69 .into(),
70 ));
71 }
72
73 crate::secrets::resolve_secrets(&mut cfg)
75 .await
76 .map_err(|e| ServeError::BadConfig(e.to_string()))?;
77
78 let nodes = expand(&cfg).map_err(|e| ServeError::Unprocessable {
80 message: e.to_string(),
81 details: None,
82 })?;
83
84 Ok(LoadedSubmission { cfg, nodes })
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90 use serde_json::json;
91
92 fn base() -> Value {
93 json!({
94 "version": 1,
95 "pipeline": {
96 "source": { "type": "csv", "config": { "path": "DEFAULT.csv" } },
97 "sink": { "type": "jsonl", "config": { "path": "out.jsonl" } }
98 }
99 })
100 }
101
102 #[tokio::test]
103 async fn submitted_overrides_default() {
104 let body = r#"{ "pipeline": { "source": { "config": { "path": "OVERRIDE.csv" } } } }"#;
105 let loaded = load_submission(body, ConfigFormat::Json, Some(&base()))
106 .await
107 .unwrap();
108 let node = &loaded.nodes[0];
110 assert_eq!(node.source.config["path"], "OVERRIDE.csv");
111 assert_eq!(node.sink.config["path"], "out.jsonl");
112 }
113
114 #[tokio::test]
115 async fn missing_version_without_base_is_unprocessable() {
116 let body = r#"{ "pipeline": {} }"#;
119 let err = load_submission(body, ConfigFormat::Json, None)
120 .await
121 .unwrap_err();
122 assert!(matches!(
123 err,
124 ServeError::Unprocessable { .. } | ServeError::BadConfig(_)
125 ));
126 }
127
128 #[cfg(feature = "schedule")]
129 #[tokio::test]
130 async fn schedule_block_is_rejected() {
131 let body = r#"
132version: 1
133pipeline:
134 source: { type: csv, config: { path: x.csv } }
135 sink: { type: jsonl, config: { path: out.jsonl } }
136schedule:
137 cron: "0 * * * *"
138 timezone: UTC
139"#;
140 let err = load_submission(body, ConfigFormat::Yaml, None)
141 .await
142 .unwrap_err();
143 match err {
144 ServeError::BadConfig(m) => assert!(m.contains("schedule:")),
145 other => panic!("expected BadConfig, got {other:?}"),
146 }
147 }
148
149 #[tokio::test]
150 async fn invalid_yaml_is_bad_config() {
151 let err = load_submission("{[bad", ConfigFormat::Yaml, None)
152 .await
153 .unwrap_err();
154 assert!(matches!(err, ServeError::BadConfig(_)));
155 }
156
157 #[tokio::test]
158 async fn submitted_extends_is_rejected_with_composition_hint() {
159 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";
164 let err = load_submission(body, ConfigFormat::Yaml, None)
165 .await
166 .unwrap_err();
167 let msg = match &err {
169 ServeError::Unprocessable { message, .. } => message.clone(),
170 ServeError::BadConfig(m) => m.clone(),
171 other => format!("{other:?}"),
172 };
173 assert!(
176 msg.contains("composition"),
177 "submitted extends must be rejected with the composition hint, got: {msg}"
178 );
179 }
180}