1use crate::registry::{sink_kinds, sink_schema, source_kinds, source_schema};
26use serde_json::{Map, Value, json};
27
28pub fn config_schema() -> Value {
30 let mut root = serde_json::to_value(faucet_core::schema_for!(crate::config::PipelineConfig))
31 .unwrap_or_else(|_| json!({"type": "object"}));
32
33 let root_obj = match root.as_object_mut() {
34 Some(o) => o,
35 None => return root,
36 };
37 root_obj.insert(
38 "title".into(),
39 json!("faucet pipeline configuration (faucet.yaml / faucet.json)"),
40 );
41
42 let mut extra_defs: Map<String, Value> = Map::new();
44 let source_union = connector_union(
45 "source",
46 &source_kinds(),
47 source_schema,
48 true,
49 &mut extra_defs,
50 );
51 let sink_union = connector_union("sink", &sink_kinds(), sink_schema, false, &mut extra_defs);
52
53 let defs_key = if root_obj.contains_key("definitions") && !root_obj.contains_key("$defs") {
54 "definitions"
55 } else {
56 "$defs"
57 };
58 let defs = root_obj
59 .entry(defs_key.to_string())
60 .or_insert_with(|| json!({}))
61 .as_object_mut()
62 .expect("$defs is an object");
63 for (k, v) in extra_defs {
64 defs.insert(k, v);
65 }
66 defs.insert("SourceConnector".into(), source_union);
67 defs.insert("SinkConnector".into(), sink_union);
68
69 let ref_prefix = format!("#/{defs_key}/");
72 if let Some(pipeline_spec) = defs.get_mut("PipelineSpec").and_then(Value::as_object_mut)
73 && let Some(props) = pipeline_spec
74 .get_mut("properties")
75 .and_then(Value::as_object_mut)
76 {
77 retarget_property(
78 props,
79 "source",
80 &format!("{ref_prefix}SourceConnector"),
81 false,
82 );
83 retarget_property(props, "sink", &format!("{ref_prefix}SinkConnector"), false);
84 retarget_property(
85 props,
86 "sources",
87 &format!("{ref_prefix}SourceConnector"),
88 true,
89 );
90 retarget_property(props, "sinks", &format!("{ref_prefix}SinkConnector"), true);
91 }
92
93 root
94}
95
96fn retarget_property(
101 props: &mut Map<String, Value>,
102 key: &str,
103 target_ref: &str,
104 map_valued: bool,
105) {
106 if !props.contains_key(key) {
107 return;
108 }
109 let new = if map_valued {
110 json!({
111 "type": ["object", "null"],
112 "additionalProperties": { "$ref": target_ref },
113 })
114 } else {
115 json!({ "anyOf": [ { "$ref": target_ref }, { "type": "null" } ] })
116 };
117 props.insert(key.to_string(), new);
118}
119
120fn connector_union(
124 ns: &str,
125 kinds: &[&str],
126 schema_fn: fn(&str) -> crate::error::CliResult<Value>,
127 source_side: bool,
128 extra_defs: &mut Map<String, Value>,
129) -> Value {
130 let mut variants = Vec::new();
131 for &kind in kinds {
132 let typed = match schema_fn(kind) {
140 Ok(s) => embed_config(s, &format!("{ns}_{kind}"), extra_defs),
141 Err(_) => json!(true),
142 };
143 let config = json!({ "anyOf": [typed, true] });
144 let mut props = Map::new();
145 props.insert("type".into(), json!({ "const": kind }));
146 props.insert("config".into(), config);
147 if source_side {
148 props.insert("transforms".into(), json!({ "type": ["array", "null"] }));
149 props.insert("inherit_transforms".into(), json!({ "type": "boolean" }));
150 }
151 variants.push(json!({
152 "type": "object",
153 "title": kind,
154 "properties": props,
155 "required": ["type"],
156 "additionalProperties": false,
157 }));
158 }
159 json!({ "oneOf": variants })
160}
161
162fn embed_config(mut schema: Value, ns: &str, extra_defs: &mut Map<String, Value>) -> Value {
166 if let Some(obj) = schema.as_object_mut() {
168 for key in ["$defs", "definitions"] {
169 if let Some(Value::Object(inner)) = obj.remove(key) {
170 for (name, mut def) in inner {
171 let ns_name = format!("{ns}__{name}");
172 rewrite_refs(&mut def, ns);
173 relax_for_interpolation(&mut def);
174 extra_defs.insert(ns_name, def);
175 }
176 }
177 }
178 obj.remove("$schema");
179 obj.remove("$id");
180 }
181 rewrite_refs(&mut schema, ns);
182 relax_for_interpolation(&mut schema);
183 schema
184}
185
186fn rewrite_refs(value: &mut Value, ns: &str) {
190 match value {
191 Value::Object(map) => {
192 if let Some(Value::String(r)) = map.get_mut("$ref") {
193 for prefix in ["#/$defs/", "#/definitions/"] {
194 if let Some(name) = r.strip_prefix(prefix) {
195 *r = format!("#/$defs/{ns}__{name}");
196 break;
197 }
198 }
199 }
200 for v in map.values_mut() {
201 rewrite_refs(v, ns);
202 }
203 }
204 Value::Array(arr) => {
205 for v in arr {
206 rewrite_refs(v, ns);
207 }
208 }
209 _ => {}
210 }
211}
212
213fn relax_for_interpolation(value: &mut Value) {
218 let Value::Object(map) = value else {
219 if let Value::Array(arr) = value {
220 for v in arr {
221 relax_for_interpolation(v);
222 }
223 }
224 return;
225 };
226
227 map.remove("required");
228
229 if !map.contains_key("$ref") {
231 if let Some(t) = map.get_mut("type") {
233 *t = allow_string(std::mem::take(t));
234 }
235 if map
237 .get("additionalProperties")
238 .map(|v| v == &json!(false))
239 .unwrap_or(false)
240 {
241 map.insert("additionalProperties".into(), json!(true));
242 }
243 }
244
245 for (k, v) in map.iter_mut() {
246 if k == "enum" || k == "const" {
248 continue;
249 }
250 relax_for_interpolation(v);
251 }
252}
253
254fn allow_string(t: Value) -> Value {
261 match &t {
262 Value::String(s) if matches!(s.as_str(), "integer" | "number" | "boolean") => {
263 json!([s, "string"])
264 }
265 Value::Array(arr)
268 if arr
269 .iter()
270 .any(|v| matches!(v.as_str(), Some("integer" | "number" | "boolean")))
271 && !arr.iter().any(|v| v == &json!("string")) =>
272 {
273 let mut arr = arr.clone();
274 arr.push(json!("string"));
275 Value::Array(arr)
276 }
277 _ => t,
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn composed_schema_has_top_level_grammar() {
287 let s = config_schema();
288 assert_eq!(s["type"], "object");
289 let props = &s["properties"];
290 for key in [
291 "version",
292 "name",
293 "pipeline",
294 "matrix",
295 "execution",
296 "auth",
297 "vars",
298 "params",
299 ] {
300 assert!(
301 props.get(key).is_some(),
302 "missing top-level property `{key}`"
303 );
304 }
305 }
306
307 #[cfg(all(feature = "source-csv", feature = "sink-jsonl"))]
308 #[test]
309 fn source_and_sink_unions_discriminate_by_kind() {
310 let s = config_schema();
311 let defs = s.get("$defs").or_else(|| s.get("definitions")).unwrap();
312 let source_union = &defs["SourceConnector"]["oneOf"];
313 let has_csv = source_union
314 .as_array()
315 .unwrap()
316 .iter()
317 .any(|v| v["properties"]["type"]["const"] == json!("csv"));
318 assert!(has_csv, "source union should have a csv branch");
319
320 let sink_union = &defs["SinkConnector"]["oneOf"];
321 let has_jsonl = sink_union
322 .as_array()
323 .unwrap()
324 .iter()
325 .any(|v| v["properties"]["type"]["const"] == json!("jsonl"));
326 assert!(has_jsonl, "sink union should have a jsonl branch");
327 }
328
329 #[test]
330 fn allow_string_broadens_only_interpolatable_scalars() {
331 assert_eq!(allow_string(json!("integer")), json!(["integer", "string"]));
332 assert_eq!(allow_string(json!("number")), json!(["number", "string"]));
333 assert_eq!(allow_string(json!("boolean")), json!(["boolean", "string"]));
334 assert_eq!(allow_string(json!("string")), json!("string"));
337 assert_eq!(allow_string(json!("object")), json!("object"));
338 assert_eq!(allow_string(json!("array")), json!("array"));
339 assert_eq!(
340 allow_string(json!(["integer", "null"])),
341 json!(["integer", "null", "string"])
342 );
343 assert_eq!(
346 allow_string(json!(["object", "null"])),
347 json!(["object", "null"])
348 );
349 }
350
351 #[test]
352 fn relax_drops_required_and_opens_objects() {
353 let mut v = json!({
354 "type": "object",
355 "required": ["a"],
356 "additionalProperties": false,
357 "properties": { "a": { "type": "integer" } }
358 });
359 relax_for_interpolation(&mut v);
360 assert!(v.get("required").is_none());
361 assert_eq!(v["additionalProperties"], json!(true));
362 assert_eq!(v["properties"]["a"]["type"], json!(["integer", "string"]));
363 }
364
365 #[test]
366 fn rewrite_refs_namespaces_defs() {
367 let mut v = json!({ "$ref": "#/$defs/Auth" });
368 rewrite_refs(&mut v, "source_rest");
369 assert_eq!(v["$ref"], json!("#/$defs/source_rest__Auth"));
370 }
371}