Skip to main content

faucet_cli/
schema_compose.rs

1//! Compose the top-level JSON Schema for a whole `faucet.yaml` config (#213).
2//!
3//! `faucet schema source <name>` already emits per-connector schemas, but there
4//! was no single schema for the *entire* config document. This module builds
5//! one by taking the derived [`PipelineConfig`](crate::config::PipelineConfig)
6//! schema (which covers `version` / `name` / `pipeline` / `matrix` /
7//! `execution` / `auth` / `vars` / `params` / `schedule` / `lineage` / `quality` / `dlq` /
8//! … — every block whose type derives `JsonSchema`) and layering per-connector
9//! discrimination on top: the `source` / `sink` positions become a `oneOf` over
10//! the compiled-in connector kinds, each branch pinning `type: <kind>` and
11//! embedding that connector's own config schema.
12//!
13//! Editors (the VS Code YAML extension, JetBrains) consume the emitted schema
14//! for autocomplete, inline docs, and as-you-type validation via a
15//! `# yaml-language-server: $schema=…` header.
16//!
17//! **Interpolation tolerance.** faucet configs pervasively use `${env:…}` /
18//! `${vars:…}` / `${now.*}` placeholders — a string standing in for a typed
19//! value. So every embedded connector-config subtree is *relaxed*: `required`
20//! is dropped, `additionalProperties` is opened, and every declared scalar type
21//! also accepts a `string`. This keeps property/description autocomplete while
22//! never rejecting a valid-but-interpolated config. The strict top-level grammar
23//! (unknown-key rejection, `version`, block shapes) is preserved.
24
25use crate::registry::{sink_kinds, sink_schema, source_kinds, source_schema};
26use serde_json::{Map, Value, json};
27
28/// Build the composed top-level config schema.
29pub 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    // Accumulate namespaced connector-config `$defs` here, then merge once.
43    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    // Point the source/sink positions of PipelineSpec at the discriminated
70    // unions. When the derived schema uses a different `$defs` key we mirror it.
71    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
96/// Replace a `PipelineSpec` property that references `ConnectorSpec` with one
97/// that references the discriminated union. `map_valued` handles the
98/// `sources` / `sinks` maps (`additionalProperties`), otherwise the singular
99/// nullable `source` / `sink`.
100fn 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
120/// Build a `oneOf` connector schema discriminated by `type`, embedding each
121/// kind's (relaxed) config schema. `source_side` connectors additionally allow
122/// `transforms` / `inherit_transforms` at the connector level.
123fn 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        // The connector's typed config schema powers editor autocomplete, but
133        // it is *advisory*, not hard-rejecting: faucet threads the raw `config`
134        // Value straight through to the connector's own `Deserialize`, and a
135        // connector's serde shape can differ from its schemars projection (e.g.
136        // an enum accepting both a bare string and a `{type: …}` object). So we
137        // wrap it as `anyOf: [<typed>, true]` — editors still surface the typed
138        // fields, but a config the connector accepts is never flagged invalid.
139        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
162/// Take a connector's standalone config schema, lift its internal `$defs` into
163/// the shared map under a namespace (rewriting refs so they don't collide),
164/// strip the schema metadata, and relax it for interpolation tolerance.
165fn embed_config(mut schema: Value, ns: &str, extra_defs: &mut Map<String, Value>) -> Value {
166    // Pull out and namespace the connector's own definitions.
167    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
186/// Rewrite every `#/$defs/X` / `#/definitions/X` ref to `#/$defs/{ns}__X` so a
187/// connector's definitions can live alongside every other connector's in one
188/// shared `$defs` map without name collisions.
189fn 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
213/// Relax a connector-config schema so interpolated configs still validate:
214/// drop `required`, open `additionalProperties`, and let every declared scalar
215/// type also be a `string` (a `${…}` placeholder). Recurses through
216/// `properties`, `items`, `additionalProperties`, and `oneOf`/`anyOf`/`allOf`.
217fn 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    // A `$ref` node's target is relaxed where it is defined; don't touch it.
230    if !map.contains_key("$ref") {
231        // Allow a string wherever a typed scalar is expected.
232        if let Some(t) = map.get_mut("type") {
233            *t = allow_string(std::mem::take(t));
234        }
235        // Open objects so extra (interpolated / future) keys don't fail.
236        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        // `enum`/`const` values are data, not schemas — leave them intact.
247        if k == "enum" || k == "const" {
248            continue;
249        }
250        relax_for_interpolation(v);
251    }
252}
253
254/// Broaden an interpolatable scalar `type` to also accept a `string` (a
255/// `${…}` placeholder). Only `integer` / `number` / `boolean` are broadened:
256/// an `${env:…}` substitution replaces a scalar, never an object/array, and
257/// broadening an object/array type would let a bare-string `oneOf` unit variant
258/// (e.g. `column_mapping: auto_map`) also match its object sibling — an
259/// `oneOf` ambiguity that fails validation.
260fn 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        // Arrays like `["integer","null"]` → add "string" only when a numeric /
266        // boolean type is present (never for object/array/string unions).
267        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        // Left alone: strings, objects, arrays, and null (broadening these
335        // would create `oneOf` ambiguity).
336        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        // An object/string union (an enum unit variant + its object sibling)
344        // must NOT gain another string.
345        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}