Skip to main content

faucet_core/contract/
export.rs

1//! Machine-readable contract exports: JSON Schema and an OpenLineage
2//! `SchemaDatasetFacet`. Pure functions over a [`ContractSpec`] — no I/O.
3//! Used by `faucet contract --export …`; available to library callers too.
4
5use crate::contract::config::{ContractFieldType, ContractSpec, FieldContract};
6use serde_json::{Map, Value, json};
7
8/// Pinned OpenLineage `SchemaDatasetFacet` schema URL used by
9/// [`to_openlineage_facet`].
10pub const OPENLINEAGE_SCHEMA_FACET_URL: &str =
11    "https://openlineage.io/spec/facets/1-1-1/SchemaDatasetFacet.json";
12
13/// Render the contract as a standalone JSON Schema (draft 2020-12) document.
14///
15/// Mapping: each field becomes a property; `required: true` fields land in
16/// the top-level `required` array; `nullable` widens the property `type` to
17/// `[…, "null"]` (and appends `null` to `enum` when both are set);
18/// `allow_extra_fields: false` maps to `additionalProperties: false`. The
19/// contract version travels as `x-faucet-contract-version`.
20pub fn to_json_schema(spec: &ContractSpec) -> Value {
21    let mut properties = Map::new();
22    let mut required: Vec<Value> = Vec::new();
23    for f in &spec.fields {
24        if f.required {
25            required.push(Value::String(f.name.clone()));
26        }
27        properties.insert(f.name.clone(), field_schema(f));
28    }
29    let mut root = Map::new();
30    root.insert(
31        "$schema".into(),
32        json!("https://json-schema.org/draft/2020-12/schema"),
33    );
34    root.insert(
35        "title".into(),
36        json!(format!("faucet data contract v{}", spec.version)),
37    );
38    if let Some(d) = &spec.description {
39        root.insert("description".into(), json!(d));
40    }
41    root.insert("x-faucet-contract-version".into(), json!(spec.version));
42    if let Some(o) = &spec.owner {
43        root.insert("x-faucet-contract-owner".into(), json!(o));
44    }
45    root.insert("type".into(), json!("object"));
46    root.insert("properties".into(), Value::Object(properties));
47    root.insert("required".into(), Value::Array(required));
48    root.insert(
49        "additionalProperties".into(),
50        json!(spec.allow_extra_fields),
51    );
52    Value::Object(root)
53}
54
55fn json_schema_type(ty: ContractFieldType) -> &'static str {
56    match ty {
57        ContractFieldType::String => "string",
58        ContractFieldType::Integer => "integer",
59        ContractFieldType::Number => "number",
60        ContractFieldType::Boolean => "boolean",
61        ContractFieldType::Object => "object",
62        ContractFieldType::Array => "array",
63    }
64}
65
66fn field_schema(f: &FieldContract) -> Value {
67    let mut prop = Map::new();
68    let base = json_schema_type(f.field_type);
69    if f.nullable {
70        prop.insert("type".into(), json!([base, "null"]));
71    } else {
72        prop.insert("type".into(), json!(base));
73    }
74    if let Some(values) = &f.allowed_values {
75        let mut e = values.clone();
76        if f.nullable {
77            e.push(Value::Null);
78        }
79        prop.insert("enum".into(), Value::Array(e));
80    }
81    if let Some(p) = &f.pattern {
82        prop.insert("pattern".into(), json!(p));
83    }
84    if let Some(min) = f.min {
85        prop.insert("minimum".into(), json!(min));
86    }
87    if let Some(max) = f.max {
88        prop.insert("maximum".into(), json!(max));
89    }
90    if let Some(min) = f.min_length {
91        prop.insert("minLength".into(), json!(min));
92    }
93    if let Some(max) = f.max_length {
94        prop.insert("maxLength".into(), json!(max));
95    }
96    if let Some(d) = &f.description {
97        prop.insert("description".into(), json!(d));
98    }
99    Value::Object(prop)
100}
101
102/// Render the contract as an OpenLineage `SchemaDatasetFacet` — the same
103/// facet shape `faucet-lineage` attaches to dataset events, so downstream
104/// OpenLineage consumers can ingest the contract as a schema promise.
105///
106/// `producer` identifies the emitting tool (e.g. the faucet repo URL + version).
107pub fn to_openlineage_facet(spec: &ContractSpec, producer: &str) -> Value {
108    let fields: Vec<Value> = spec
109        .fields
110        .iter()
111        .map(|f| {
112            let mut entry = Map::new();
113            entry.insert("name".into(), json!(f.name));
114            entry.insert("type".into(), json!(json_schema_type(f.field_type)));
115            if let Some(d) = &f.description {
116                entry.insert("description".into(), json!(d));
117            }
118            Value::Object(entry)
119        })
120        .collect();
121    json!({
122        "_producer": producer,
123        "_schemaURL": OPENLINEAGE_SCHEMA_FACET_URL,
124        "x-faucet-contract-version": spec.version,
125        "fields": fields,
126    })
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use serde_json::json;
133
134    fn spec() -> ContractSpec {
135        serde_json::from_value(json!({
136            "version": "2.1.0",
137            "description": "orders",
138            "owner": "data-platform",
139            "allow_extra_fields": false,
140            "fields": [
141                { "name": "id", "type": "integer", "min": 1.0, "max": 100.0 },
142                { "name": "status", "type": "string", "enum": ["open", "closed"],
143                  "nullable": true, "description": "lifecycle" },
144                { "name": "email", "type": "string", "pattern": "^.+@.+$",
145                  "required": false, "min_length": 3, "max_length": 254 }
146            ]
147        }))
148        .unwrap()
149    }
150
151    #[test]
152    fn json_schema_maps_types_required_and_constraints() {
153        let s = to_json_schema(&spec());
154        assert_eq!(s["x-faucet-contract-version"], "2.1.0");
155        assert_eq!(s["x-faucet-contract-owner"], "data-platform");
156        assert_eq!(s["description"], "orders");
157        assert_eq!(s["type"], "object");
158        assert_eq!(s["additionalProperties"], false);
159        assert_eq!(s["required"], json!(["id", "status"]));
160        assert_eq!(s["properties"]["id"]["type"], "integer");
161        assert_eq!(s["properties"]["id"]["minimum"], 1.0);
162        assert_eq!(s["properties"]["id"]["maximum"], 100.0);
163        // nullable widens type and appends null to enum
164        assert_eq!(s["properties"]["status"]["type"], json!(["string", "null"]));
165        assert_eq!(
166            s["properties"]["status"]["enum"],
167            json!(["open", "closed", null])
168        );
169        assert_eq!(s["properties"]["status"]["description"], "lifecycle");
170        assert_eq!(s["properties"]["email"]["pattern"], "^.+@.+$");
171        assert_eq!(s["properties"]["email"]["minLength"], 3);
172        assert_eq!(s["properties"]["email"]["maxLength"], 254);
173    }
174
175    #[test]
176    fn json_schema_allows_extra_fields_by_default() {
177        let spec: ContractSpec = serde_json::from_value(json!({
178            "version": "1", "fields": [{ "name": "id", "type": "string" }]
179        }))
180        .unwrap();
181        let s = to_json_schema(&spec);
182        assert_eq!(s["additionalProperties"], true);
183        assert!(s.get("x-faucet-contract-owner").is_none());
184    }
185
186    #[test]
187    fn openlineage_facet_shape() {
188        let f = to_openlineage_facet(&spec(), "https://example.com/faucet/v1");
189        assert_eq!(f["_producer"], "https://example.com/faucet/v1");
190        assert_eq!(f["_schemaURL"], OPENLINEAGE_SCHEMA_FACET_URL);
191        assert_eq!(f["x-faucet-contract-version"], "2.1.0");
192        let fields = f["fields"].as_array().unwrap();
193        assert_eq!(fields.len(), 3);
194        assert_eq!(fields[0]["name"], "id");
195        assert_eq!(fields[0]["type"], "integer");
196        assert!(fields[0].get("description").is_none());
197        assert_eq!(fields[1]["description"], "lifecycle");
198    }
199}