use crate::registry::{sink_kinds, sink_schema, source_kinds, source_schema};
use serde_json::{Map, Value, json};
pub fn config_schema() -> Value {
let mut root = serde_json::to_value(faucet_core::schema_for!(crate::config::PipelineConfig))
.unwrap_or_else(|_| json!({"type": "object"}));
let root_obj = match root.as_object_mut() {
Some(o) => o,
None => return root,
};
root_obj.insert(
"title".into(),
json!("faucet pipeline configuration (faucet.yaml / faucet.json)"),
);
let mut extra_defs: Map<String, Value> = Map::new();
let source_union = connector_union(
"source",
&source_kinds(),
source_schema,
true,
&mut extra_defs,
);
let sink_union = connector_union("sink", &sink_kinds(), sink_schema, false, &mut extra_defs);
let defs_key = if root_obj.contains_key("definitions") && !root_obj.contains_key("$defs") {
"definitions"
} else {
"$defs"
};
let defs = root_obj
.entry(defs_key.to_string())
.or_insert_with(|| json!({}))
.as_object_mut()
.expect("$defs is an object");
for (k, v) in extra_defs {
defs.insert(k, v);
}
defs.insert("SourceConnector".into(), source_union);
defs.insert("SinkConnector".into(), sink_union);
let ref_prefix = format!("#/{defs_key}/");
if let Some(pipeline_spec) = defs.get_mut("PipelineSpec").and_then(Value::as_object_mut)
&& let Some(props) = pipeline_spec
.get_mut("properties")
.and_then(Value::as_object_mut)
{
retarget_property(
props,
"source",
&format!("{ref_prefix}SourceConnector"),
false,
);
retarget_property(props, "sink", &format!("{ref_prefix}SinkConnector"), false);
retarget_property(
props,
"sources",
&format!("{ref_prefix}SourceConnector"),
true,
);
retarget_property(props, "sinks", &format!("{ref_prefix}SinkConnector"), true);
}
root
}
fn retarget_property(
props: &mut Map<String, Value>,
key: &str,
target_ref: &str,
map_valued: bool,
) {
if !props.contains_key(key) {
return;
}
let new = if map_valued {
json!({
"type": ["object", "null"],
"additionalProperties": { "$ref": target_ref },
})
} else {
json!({ "anyOf": [ { "$ref": target_ref }, { "type": "null" } ] })
};
props.insert(key.to_string(), new);
}
fn connector_union(
ns: &str,
kinds: &[&str],
schema_fn: fn(&str) -> crate::error::CliResult<Value>,
source_side: bool,
extra_defs: &mut Map<String, Value>,
) -> Value {
let mut variants = Vec::new();
for &kind in kinds {
let typed = match schema_fn(kind) {
Ok(s) => embed_config(s, &format!("{ns}_{kind}"), extra_defs),
Err(_) => json!(true),
};
let config = json!({ "anyOf": [typed, true] });
let mut props = Map::new();
props.insert("type".into(), json!({ "const": kind }));
props.insert("config".into(), config);
if source_side {
props.insert("transforms".into(), json!({ "type": ["array", "null"] }));
props.insert("inherit_transforms".into(), json!({ "type": "boolean" }));
}
variants.push(json!({
"type": "object",
"title": kind,
"properties": props,
"required": ["type"],
"additionalProperties": false,
}));
}
json!({ "oneOf": variants })
}
fn embed_config(mut schema: Value, ns: &str, extra_defs: &mut Map<String, Value>) -> Value {
if let Some(obj) = schema.as_object_mut() {
for key in ["$defs", "definitions"] {
if let Some(Value::Object(inner)) = obj.remove(key) {
for (name, mut def) in inner {
let ns_name = format!("{ns}__{name}");
rewrite_refs(&mut def, ns);
relax_for_interpolation(&mut def);
extra_defs.insert(ns_name, def);
}
}
}
obj.remove("$schema");
obj.remove("$id");
}
rewrite_refs(&mut schema, ns);
relax_for_interpolation(&mut schema);
schema
}
fn rewrite_refs(value: &mut Value, ns: &str) {
match value {
Value::Object(map) => {
if let Some(Value::String(r)) = map.get_mut("$ref") {
for prefix in ["#/$defs/", "#/definitions/"] {
if let Some(name) = r.strip_prefix(prefix) {
*r = format!("#/$defs/{ns}__{name}");
break;
}
}
}
for v in map.values_mut() {
rewrite_refs(v, ns);
}
}
Value::Array(arr) => {
for v in arr {
rewrite_refs(v, ns);
}
}
_ => {}
}
}
fn relax_for_interpolation(value: &mut Value) {
let Value::Object(map) = value else {
if let Value::Array(arr) = value {
for v in arr {
relax_for_interpolation(v);
}
}
return;
};
map.remove("required");
if !map.contains_key("$ref") {
if let Some(t) = map.get_mut("type") {
*t = allow_string(std::mem::take(t));
}
if map
.get("additionalProperties")
.map(|v| v == &json!(false))
.unwrap_or(false)
{
map.insert("additionalProperties".into(), json!(true));
}
}
for (k, v) in map.iter_mut() {
if k == "enum" || k == "const" {
continue;
}
relax_for_interpolation(v);
}
}
fn allow_string(t: Value) -> Value {
match &t {
Value::String(s) if matches!(s.as_str(), "integer" | "number" | "boolean") => {
json!([s, "string"])
}
Value::Array(arr)
if arr
.iter()
.any(|v| matches!(v.as_str(), Some("integer" | "number" | "boolean")))
&& !arr.iter().any(|v| v == &json!("string")) =>
{
let mut arr = arr.clone();
arr.push(json!("string"));
Value::Array(arr)
}
_ => t,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn composed_schema_has_top_level_grammar() {
let s = config_schema();
assert_eq!(s["type"], "object");
let props = &s["properties"];
for key in [
"version",
"name",
"pipeline",
"matrix",
"execution",
"auth",
"vars",
] {
assert!(
props.get(key).is_some(),
"missing top-level property `{key}`"
);
}
}
#[cfg(all(feature = "source-csv", feature = "sink-jsonl"))]
#[test]
fn source_and_sink_unions_discriminate_by_kind() {
let s = config_schema();
let defs = s.get("$defs").or_else(|| s.get("definitions")).unwrap();
let source_union = &defs["SourceConnector"]["oneOf"];
let has_csv = source_union
.as_array()
.unwrap()
.iter()
.any(|v| v["properties"]["type"]["const"] == json!("csv"));
assert!(has_csv, "source union should have a csv branch");
let sink_union = &defs["SinkConnector"]["oneOf"];
let has_jsonl = sink_union
.as_array()
.unwrap()
.iter()
.any(|v| v["properties"]["type"]["const"] == json!("jsonl"));
assert!(has_jsonl, "sink union should have a jsonl branch");
}
#[test]
fn allow_string_broadens_only_interpolatable_scalars() {
assert_eq!(allow_string(json!("integer")), json!(["integer", "string"]));
assert_eq!(allow_string(json!("number")), json!(["number", "string"]));
assert_eq!(allow_string(json!("boolean")), json!(["boolean", "string"]));
assert_eq!(allow_string(json!("string")), json!("string"));
assert_eq!(allow_string(json!("object")), json!("object"));
assert_eq!(allow_string(json!("array")), json!("array"));
assert_eq!(
allow_string(json!(["integer", "null"])),
json!(["integer", "null", "string"])
);
assert_eq!(
allow_string(json!(["object", "null"])),
json!(["object", "null"])
);
}
#[test]
fn relax_drops_required_and_opens_objects() {
let mut v = json!({
"type": "object",
"required": ["a"],
"additionalProperties": false,
"properties": { "a": { "type": "integer" } }
});
relax_for_interpolation(&mut v);
assert!(v.get("required").is_none());
assert_eq!(v["additionalProperties"], json!(true));
assert_eq!(v["properties"]["a"]["type"], json!(["integer", "string"]));
}
#[test]
fn rewrite_refs_namespaces_defs() {
let mut v = json!({ "$ref": "#/$defs/Auth" });
rewrite_refs(&mut v, "source_rest");
assert_eq!(v["$ref"], json!("#/$defs/source_rest__Auth"));
}
}