1use crate::contract::config::{ContractFieldType, ContractSpec, FieldContract};
6use serde_json::{Map, Value, json};
7
8pub const OPENLINEAGE_SCHEMA_FACET_URL: &str =
11 "https://openlineage.io/spec/facets/1-1-1/SchemaDatasetFacet.json";
12
13pub 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
102pub 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 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}