1pub mod compile;
11pub mod config;
12pub mod export;
13
14pub use compile::{CompiledContract, CompiledField};
15pub use config::{ContractFieldType, ContractSpec, FieldContract, OnBreach};
16pub use export::{OPENLINEAGE_SCHEMA_FACET_URL, to_json_schema, to_openlineage_facet};
17
18use crate::contract::compile::type_matches;
19use crate::error::FaucetError;
20use serde_json::Value;
21
22#[derive(Debug, Clone)]
24pub struct ContractViolation {
25 pub rule: &'static str,
29 pub field: Option<String>,
31 pub message: String,
33 pub page_index: usize,
36}
37
38impl ContractViolation {
39 pub fn describe(&self) -> String {
41 match &self.field {
42 Some(f) => format!("field '{f}': {} ({})", self.message, self.rule),
43 None => format!("{} ({})", self.message, self.rule),
44 }
45 }
46}
47
48#[derive(Debug, Clone)]
51pub struct ViolatingRecord {
52 pub record: Value,
53 pub violation: ContractViolation,
54}
55
56#[derive(Debug, Clone, Default)]
58pub struct ContractOutcome {
59 pub survivors: Vec<Value>,
61 pub quarantined: Vec<ViolatingRecord>,
63 pub warned: Vec<ContractViolation>,
65}
66
67pub fn apply_contract(
75 records: Vec<Value>,
76 contract: &CompiledContract,
77) -> Result<ContractOutcome, FaucetError> {
78 let mut out = ContractOutcome::default();
79 for (page_index, rec) in records.into_iter().enumerate() {
80 match evaluate_record(&rec, contract, page_index) {
81 None => out.survivors.push(rec),
82 Some(violation) => match contract.on_breach {
83 OnBreach::Fail => {
84 return Err(FaucetError::ContractViolation {
85 version: contract.version.clone(),
86 message: violation.describe(),
87 });
88 }
89 OnBreach::Quarantine => out.quarantined.push(ViolatingRecord {
90 record: rec,
91 violation,
92 }),
93 OnBreach::Warn => {
94 out.warned.push(violation);
95 out.survivors.push(rec);
96 }
97 },
98 }
99 }
100 Ok(out)
101}
102
103fn evaluate_record(
106 rec: &Value,
107 contract: &CompiledContract,
108 page_index: usize,
109) -> Option<ContractViolation> {
110 let violation = |rule: &'static str, field: Option<&str>, message: String| ContractViolation {
111 rule,
112 field: field.map(str::to_string),
113 message,
114 page_index,
115 };
116
117 let Some(obj) = rec.as_object() else {
118 return Some(violation(
119 "not_object",
120 None,
121 format!("record is not a JSON object (got {})", json_type_name(rec)),
122 ));
123 };
124
125 for f in &contract.fields {
126 let value = match obj.get(&f.name) {
127 None => {
128 if f.required {
129 return Some(violation(
130 "missing",
131 Some(&f.name),
132 "required field is missing".into(),
133 ));
134 }
135 continue; }
137 Some(v) => v,
138 };
139 if value.is_null() {
140 if f.nullable {
141 continue; }
143 return Some(violation(
144 "null",
145 Some(&f.name),
146 "field is null but not nullable".into(),
147 ));
148 }
149 if !type_matches(value, f.field_type) {
150 return Some(violation(
151 "type",
152 Some(&f.name),
153 format!("expected {}, got {}", f.field_type, json_type_name(value)),
154 ));
155 }
156 if let Some(values) = &f.allowed_values
157 && !values.iter().any(|v| v == value)
158 {
159 return Some(violation(
160 "enum",
161 Some(&f.name),
162 format!("value {value} is not in the allowed set"),
163 ));
164 }
165 if let Some(re) = &f.pattern {
166 let s = value.as_str().unwrap_or_default();
168 if !re.is_match(s) {
169 return Some(violation(
170 "pattern",
171 Some(&f.name),
172 format!("value {value} does not match pattern '{re}'"),
173 ));
174 }
175 }
176 if (f.min.is_some() || f.max.is_some())
177 && let Some(n) = value.as_f64()
178 {
179 if let Some(min) = f.min
180 && n < min
181 {
182 return Some(violation(
183 "range",
184 Some(&f.name),
185 format!("value {n} is below the minimum {min}"),
186 ));
187 }
188 if let Some(max) = f.max
189 && n > max
190 {
191 return Some(violation(
192 "range",
193 Some(&f.name),
194 format!("value {n} is above the maximum {max}"),
195 ));
196 }
197 }
198 if f.min_length.is_some() || f.max_length.is_some() {
199 let len = value.as_str().map(|s| s.chars().count()).unwrap_or(0);
200 if let Some(min) = f.min_length
201 && len < min
202 {
203 return Some(violation(
204 "length",
205 Some(&f.name),
206 format!("string length {len} is below the minimum {min}"),
207 ));
208 }
209 if let Some(max) = f.max_length
210 && len > max
211 {
212 return Some(violation(
213 "length",
214 Some(&f.name),
215 format!("string length {len} is above the maximum {max}"),
216 ));
217 }
218 }
219 }
220
221 if !contract.allow_extra_fields {
222 for key in obj.keys() {
223 if !contract.declared.contains(key) {
224 return Some(violation(
225 "extra_field",
226 Some(key),
227 "field is not declared in the contract and allow_extra_fields is false".into(),
228 ));
229 }
230 }
231 }
232
233 None
234}
235
236fn json_type_name(v: &Value) -> &'static str {
237 match v {
238 Value::Null => "null",
239 Value::Bool(_) => "boolean",
240 Value::Number(_) => "number",
241 Value::String(_) => "string",
242 Value::Array(_) => "array",
243 Value::Object(_) => "object",
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use serde_json::json;
251
252 fn compiled(v: Value) -> CompiledContract {
253 let spec: ContractSpec = serde_json::from_value(v).unwrap();
254 CompiledContract::compile(&spec).unwrap()
255 }
256
257 fn basic(on_breach: &str) -> CompiledContract {
258 compiled(json!({
259 "version": "1.0.0",
260 "on_breach": on_breach,
261 "fields": [
262 { "name": "id", "type": "integer", "min": 0 },
263 { "name": "status", "type": "string", "enum": ["open", "closed"] },
264 { "name": "email", "type": "string", "required": false,
265 "nullable": true, "pattern": "^.+@.+$", "min_length": 3 }
266 ]
267 }))
268 }
269
270 #[test]
271 fn conforming_page_passes_untouched() {
272 let page = vec![
273 json!({"id": 1, "status": "open", "email": "a@b.c"}),
274 json!({"id": 2, "status": "closed"}), json!({"id": 3, "status": "open", "email": null}), ];
277 let out = apply_contract(page.clone(), &basic("fail")).unwrap();
278 assert_eq!(out.survivors, page);
279 assert!(out.quarantined.is_empty());
280 assert!(out.warned.is_empty());
281 }
282
283 #[test]
284 fn fail_raises_typed_error_with_version_and_field() {
285 let page = vec![
286 json!({"id": 1, "status": "open"}),
287 json!({"id": 2, "status": "bogus"}),
288 ];
289 let err = apply_contract(page, &basic("fail")).unwrap_err();
290 match err {
291 FaucetError::ContractViolation { version, message } => {
292 assert_eq!(version, "1.0.0");
293 assert!(message.contains("status"), "message: {message}");
294 assert!(message.contains("enum"), "message: {message}");
295 }
296 other => panic!("expected ContractViolation, got {other:?}"),
297 }
298 }
299
300 #[test]
301 fn quarantine_partitions_page_and_keeps_page_index() {
302 let page = vec![
303 json!({"id": 1, "status": "open"}),
304 json!({"id": -5, "status": "open"}), json!({"id": 3, "status": "open"}),
306 json!({"id": 4, "status": "nope"}), ];
308 let out = apply_contract(page, &basic("quarantine")).unwrap();
309 assert_eq!(out.survivors.len(), 2);
310 let idx: Vec<usize> = out
311 .quarantined
312 .iter()
313 .map(|q| q.violation.page_index)
314 .collect();
315 assert_eq!(idx, vec![1, 3], "page_index must be the original position");
316 assert_eq!(out.quarantined[0].violation.rule, "range");
317 assert_eq!(out.quarantined[1].violation.rule, "enum");
318 }
319
320 #[test]
321 fn warn_keeps_all_records_and_reports_breaches() {
322 let page = vec![
323 json!({"id": 1, "status": "open"}),
324 json!({"id": "not-an-int", "status": "open"}),
325 ];
326 let out = apply_contract(page.clone(), &basic("warn")).unwrap();
327 assert_eq!(out.survivors, page, "warn must not remove records");
328 assert!(out.quarantined.is_empty());
329 assert_eq!(out.warned.len(), 1);
330 assert_eq!(out.warned[0].rule, "type");
331 assert_eq!(out.warned[0].field.as_deref(), Some("id"));
332 assert_eq!(out.warned[0].page_index, 1);
333 }
334
335 #[test]
336 fn missing_required_field_breaches() {
337 let out = apply_contract(vec![json!({"status": "open"})], &basic("warn")).unwrap();
338 assert_eq!(out.warned[0].rule, "missing");
339 assert_eq!(out.warned[0].field.as_deref(), Some("id"));
340 }
341
342 #[test]
343 fn null_on_non_nullable_breaches() {
344 let out =
345 apply_contract(vec![json!({"id": null, "status": "open"})], &basic("warn")).unwrap();
346 assert_eq!(out.warned[0].rule, "null");
347 }
348
349 #[test]
350 fn pattern_and_length_breaches() {
351 let c = basic("warn");
352 let out = apply_contract(
353 vec![json!({"id": 1, "status": "open", "email": "nope"})],
354 &c,
355 )
356 .unwrap();
357 assert_eq!(out.warned[0].rule, "pattern");
358 let out =
359 apply_contract(vec![json!({"id": 1, "status": "open", "email": "@b"})], &c).unwrap();
360 assert_eq!(out.warned[0].rule, "pattern");
363 let out =
364 apply_contract(vec![json!({"id": 1, "status": "open", "email": "a@c"})], &c).unwrap();
365 assert!(out.warned.is_empty(), "a@c matches pattern and min_length");
366 }
367
368 #[test]
369 fn min_length_breach_reported() {
370 let c = compiled(json!({
371 "version": "1", "on_breach": "warn",
372 "fields": [{ "name": "s", "type": "string", "min_length": 3, "max_length": 5 }]
373 }));
374 let out = apply_contract(vec![json!({"s": "ab"})], &c).unwrap();
375 assert_eq!(out.warned[0].rule, "length");
376 let out = apply_contract(vec![json!({"s": "abcdef"})], &c).unwrap();
377 assert_eq!(out.warned[0].rule, "length");
378 let out = apply_contract(vec![json!({"s": "héllo"})], &c).unwrap();
380 assert!(out.warned.is_empty());
381 }
382
383 #[test]
384 fn integer_type_rejects_float_value() {
385 let out =
386 apply_contract(vec![json!({"id": 1.5, "status": "open"})], &basic("warn")).unwrap();
387 assert_eq!(out.warned[0].rule, "type");
388 }
389
390 #[test]
391 fn extra_field_breach_when_disallowed() {
392 let c = compiled(json!({
393 "version": "1", "on_breach": "warn", "allow_extra_fields": false,
394 "fields": [{ "name": "id", "type": "integer" }]
395 }));
396 let out = apply_contract(vec![json!({"id": 1, "surprise": true})], &c).unwrap();
397 assert_eq!(out.warned[0].rule, "extra_field");
398 assert_eq!(out.warned[0].field.as_deref(), Some("surprise"));
399 }
400
401 #[test]
402 fn extra_field_allowed_by_default() {
403 let c = compiled(json!({
404 "version": "1", "on_breach": "warn",
405 "fields": [{ "name": "id", "type": "integer" }]
406 }));
407 let out = apply_contract(vec![json!({"id": 1, "surprise": true})], &c).unwrap();
408 assert!(out.warned.is_empty());
409 }
410
411 #[test]
412 fn non_object_record_breaches() {
413 let out = apply_contract(vec![json!([1, 2, 3])], &basic("warn")).unwrap();
414 assert_eq!(out.warned[0].rule, "not_object");
415 assert!(out.warned[0].field.is_none());
416 }
417
418 #[test]
419 fn first_breach_wins_in_declared_field_order() {
420 let out =
422 apply_contract(vec![json!({"id": "x", "status": "nope"})], &basic("warn")).unwrap();
423 assert_eq!(out.warned.len(), 1, "one breach per record");
424 assert_eq!(out.warned[0].field.as_deref(), Some("id"));
425 }
426
427 #[test]
428 fn range_bounds_inclusive() {
429 let c = compiled(json!({
430 "version": "1", "on_breach": "warn",
431 "fields": [{ "name": "n", "type": "number", "min": 0.0, "max": 10.0 }]
432 }));
433 let out = apply_contract(vec![json!({"n": 0.0}), json!({"n": 10.0})], &c).unwrap();
434 assert!(out.warned.is_empty(), "bounds are inclusive");
435 let out = apply_contract(vec![json!({"n": 10.001})], &c).unwrap();
436 assert_eq!(out.warned[0].rule, "range");
437 }
438
439 #[test]
440 fn violation_describe_includes_field_and_rule() {
441 let v = ContractViolation {
442 rule: "enum",
443 field: Some("status".into()),
444 message: "value \"x\" is not in the allowed set".into(),
445 page_index: 0,
446 };
447 let d = v.describe();
448 assert!(d.contains("field 'status'"));
449 assert!(d.contains("(enum)"));
450 let v2 = ContractViolation {
451 rule: "not_object",
452 field: None,
453 message: "record is not a JSON object (got array)".into(),
454 page_index: 0,
455 };
456 assert!(!v2.describe().contains("field"));
457 }
458}