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 && !enum_contains(values, value)
158 {
159 return Some(violation(
160 "enum",
161 Some(&f.name),
162 "value is not in the allowed set".into(),
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 does not match pattern '{re}'"),
173 ));
174 }
175 }
176 if (f.min.is_some() || f.max.is_some())
177 && let Value::Number(n) = value
178 {
179 if let Some(min) = f.min
180 && cmp_number_to_bound(n, min) == Some(std::cmp::Ordering::Less)
181 {
182 return Some(violation(
183 "range",
184 Some(&f.name),
185 format!("value is below the minimum {min}"),
186 ));
187 }
188 if let Some(max) = f.max
189 && cmp_number_to_bound(n, max) == Some(std::cmp::Ordering::Greater)
190 {
191 return Some(violation(
192 "range",
193 Some(&f.name),
194 format!("value 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 cmp_number_to_bound(n: &serde_json::Number, bound: f64) -> Option<std::cmp::Ordering> {
239 let int = n
240 .as_i64()
241 .map(i128::from)
242 .or_else(|| n.as_u64().map(i128::from));
243 match int {
244 Some(i) => cmp_int_to_f64(i, bound),
245 None => n.as_f64()?.partial_cmp(&bound),
246 }
247}
248
249fn cmp_int_to_f64(i: i128, bound: f64) -> Option<std::cmp::Ordering> {
250 use std::cmp::Ordering;
251 if bound.is_nan() {
252 return None;
253 }
254 let floor = bound.floor();
255 if floor >= 1.0e20 {
256 return Some(Ordering::Less);
257 }
258 if floor <= -1.0e20 {
259 return Some(Ordering::Greater);
260 }
261 match i.cmp(&(floor as i128)) {
262 Ordering::Equal if bound > floor => Some(Ordering::Less),
263 other => Some(other),
264 }
265}
266
267fn json_type_name(v: &Value) -> &'static str {
268 match v {
269 Value::Null => "null",
270 Value::Bool(_) => "boolean",
271 Value::Number(_) => "number",
272 Value::String(_) => "string",
273 Value::Array(_) => "array",
274 Value::Object(_) => "object",
275 }
276}
277
278fn enum_contains(allowed: &[Value], value: &Value) -> bool {
284 allowed.iter().any(|v| match (v, value) {
285 (Value::Number(a), Value::Number(b)) => {
286 if let (Some(x), Some(y)) = (a.as_i64(), b.as_i64()) {
287 x == y
288 } else if let (Some(x), Some(y)) = (a.as_u64(), b.as_u64()) {
289 x == y
290 } else {
291 matches!((a.as_f64(), b.as_f64()), (Some(x), Some(y)) if x == y)
292 }
293 }
294 _ => v == value,
295 })
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use serde_json::json;
302
303 fn compiled(v: Value) -> CompiledContract {
304 let spec: ContractSpec = serde_json::from_value(v).unwrap();
305 CompiledContract::compile(&spec).unwrap()
306 }
307
308 fn basic(on_breach: &str) -> CompiledContract {
309 compiled(json!({
310 "version": "1.0.0",
311 "on_breach": on_breach,
312 "fields": [
313 { "name": "id", "type": "integer", "min": 0 },
314 { "name": "status", "type": "string", "enum": ["open", "closed"] },
315 { "name": "email", "type": "string", "required": false,
316 "nullable": true, "pattern": "^.+@.+$", "min_length": 3 }
317 ]
318 }))
319 }
320
321 #[test]
322 fn breach_messages_never_carry_the_value() {
323 let c = compiled(json!({
324 "version": "1.0.0",
325 "on_breach": "fail",
326 "fields": [
327 { "name": "s", "type": "string", "required": false, "enum": ["a"] },
328 { "name": "p", "type": "string", "required": false, "pattern": "^x$" },
329 { "name": "n", "type": "integer", "required": false, "min": 0, "max": 5 }
330 ]
331 }));
332 for rec in [
333 json!({"s": "secret-enum"}),
334 json!({"p": "secret-pattern"}),
335 json!({"n": 987654321}),
336 json!({"n": -987654321}),
337 ] {
338 let err = apply_contract(vec![rec], &c).unwrap_err().to_string();
339 assert!(
340 !err.contains("secret") && !err.contains("987654321"),
341 "{err}"
342 );
343 }
344 }
345
346 #[test]
347 fn integer_accepts_whole_floats_and_bounds_compare_exactly() {
348 let c = compiled(json!({
349 "version": "1.0.0",
350 "on_breach": "fail",
351 "fields": [ { "name": "n", "type": "integer", "max": 9007199254740992.0 } ]
352 }));
353 assert!(apply_contract(vec![json!({"n": 1.0})], &c).is_ok());
354 assert!(apply_contract(vec![json!({"n": 1.5})], &c).is_err());
355 assert!(apply_contract(vec![json!({"n": 9007199254740992u64})], &c).is_ok());
356 assert!(apply_contract(vec![json!({"n": 9007199254740993u64})], &c).is_err());
358 }
359
360 #[test]
361 fn int_to_f64_bound_ordering_edges() {
362 use std::cmp::Ordering;
363 assert_eq!(cmp_int_to_f64(1, 1.5), Some(Ordering::Less));
364 assert_eq!(cmp_int_to_f64(2, 1.5), Some(Ordering::Greater));
365 assert_eq!(cmp_int_to_f64(1, 1.0), Some(Ordering::Equal));
366 assert_eq!(cmp_int_to_f64(1, f64::NAN), None);
367 assert_eq!(
368 cmp_int_to_f64(i128::from(u64::MAX), 1e30),
369 Some(Ordering::Less)
370 );
371 assert_eq!(
372 cmp_int_to_f64(i128::from(i64::MIN), -1e30),
373 Some(Ordering::Greater)
374 );
375 assert_eq!(
376 cmp_number_to_bound(&serde_json::Number::from_f64(0.5).unwrap(), 1.0),
377 Some(Ordering::Less)
378 );
379 }
380
381 #[test]
382 fn conforming_page_passes_untouched() {
383 let page = vec![
384 json!({"id": 1, "status": "open", "email": "a@b.c"}),
385 json!({"id": 2, "status": "closed"}), json!({"id": 3, "status": "open", "email": null}), ];
388 let out = apply_contract(page.clone(), &basic("fail")).unwrap();
389 assert_eq!(out.survivors, page);
390 assert!(out.quarantined.is_empty());
391 assert!(out.warned.is_empty());
392 }
393
394 #[test]
395 fn numeric_enum_matches_across_int_float_spelling() {
396 let c = compiled(json!({
399 "version": "1.0.0",
400 "on_breach": "fail",
401 "fields": [ { "name": "rate", "type": "number", "enum": [1, 2] } ]
402 }));
403 let out = apply_contract(vec![json!({"rate": 1.0})], &c).unwrap();
405 assert_eq!(out.survivors.len(), 1);
406 assert!(out.quarantined.is_empty());
407 let err = apply_contract(vec![json!({"rate": 3.0})], &c).unwrap_err();
409 assert!(matches!(err, FaucetError::ContractViolation { .. }));
410 }
411
412 #[test]
413 fn fail_raises_typed_error_with_version_and_field() {
414 let page = vec![
415 json!({"id": 1, "status": "open"}),
416 json!({"id": 2, "status": "bogus"}),
417 ];
418 let err = apply_contract(page, &basic("fail")).unwrap_err();
419 match err {
420 FaucetError::ContractViolation { version, message } => {
421 assert_eq!(version, "1.0.0");
422 assert!(message.contains("status"), "message: {message}");
423 assert!(message.contains("enum"), "message: {message}");
424 }
425 other => panic!("expected ContractViolation, got {other:?}"),
426 }
427 }
428
429 #[test]
430 fn quarantine_partitions_page_and_keeps_page_index() {
431 let page = vec![
432 json!({"id": 1, "status": "open"}),
433 json!({"id": -5, "status": "open"}), json!({"id": 3, "status": "open"}),
435 json!({"id": 4, "status": "nope"}), ];
437 let out = apply_contract(page, &basic("quarantine")).unwrap();
438 assert_eq!(out.survivors.len(), 2);
439 let idx: Vec<usize> = out
440 .quarantined
441 .iter()
442 .map(|q| q.violation.page_index)
443 .collect();
444 assert_eq!(idx, vec![1, 3], "page_index must be the original position");
445 assert_eq!(out.quarantined[0].violation.rule, "range");
446 assert_eq!(out.quarantined[1].violation.rule, "enum");
447 }
448
449 #[test]
450 fn warn_keeps_all_records_and_reports_breaches() {
451 let page = vec![
452 json!({"id": 1, "status": "open"}),
453 json!({"id": "not-an-int", "status": "open"}),
454 ];
455 let out = apply_contract(page.clone(), &basic("warn")).unwrap();
456 assert_eq!(out.survivors, page, "warn must not remove records");
457 assert!(out.quarantined.is_empty());
458 assert_eq!(out.warned.len(), 1);
459 assert_eq!(out.warned[0].rule, "type");
460 assert_eq!(out.warned[0].field.as_deref(), Some("id"));
461 assert_eq!(out.warned[0].page_index, 1);
462 }
463
464 #[test]
465 fn missing_required_field_breaches() {
466 let out = apply_contract(vec![json!({"status": "open"})], &basic("warn")).unwrap();
467 assert_eq!(out.warned[0].rule, "missing");
468 assert_eq!(out.warned[0].field.as_deref(), Some("id"));
469 }
470
471 #[test]
472 fn null_on_non_nullable_breaches() {
473 let out =
474 apply_contract(vec![json!({"id": null, "status": "open"})], &basic("warn")).unwrap();
475 assert_eq!(out.warned[0].rule, "null");
476 }
477
478 #[test]
479 fn pattern_and_length_breaches() {
480 let c = basic("warn");
481 let out = apply_contract(
482 vec![json!({"id": 1, "status": "open", "email": "nope"})],
483 &c,
484 )
485 .unwrap();
486 assert_eq!(out.warned[0].rule, "pattern");
487 let out =
488 apply_contract(vec![json!({"id": 1, "status": "open", "email": "@b"})], &c).unwrap();
489 assert_eq!(out.warned[0].rule, "pattern");
492 let out =
493 apply_contract(vec![json!({"id": 1, "status": "open", "email": "a@c"})], &c).unwrap();
494 assert!(out.warned.is_empty(), "a@c matches pattern and min_length");
495 }
496
497 #[test]
498 fn min_length_breach_reported() {
499 let c = compiled(json!({
500 "version": "1", "on_breach": "warn",
501 "fields": [{ "name": "s", "type": "string", "min_length": 3, "max_length": 5 }]
502 }));
503 let out = apply_contract(vec![json!({"s": "ab"})], &c).unwrap();
504 assert_eq!(out.warned[0].rule, "length");
505 let out = apply_contract(vec![json!({"s": "abcdef"})], &c).unwrap();
506 assert_eq!(out.warned[0].rule, "length");
507 let out = apply_contract(vec![json!({"s": "héllo"})], &c).unwrap();
509 assert!(out.warned.is_empty());
510 }
511
512 #[test]
513 fn integer_type_rejects_float_value() {
514 let out =
515 apply_contract(vec![json!({"id": 1.5, "status": "open"})], &basic("warn")).unwrap();
516 assert_eq!(out.warned[0].rule, "type");
517 }
518
519 #[test]
520 fn extra_field_breach_when_disallowed() {
521 let c = compiled(json!({
522 "version": "1", "on_breach": "warn", "allow_extra_fields": false,
523 "fields": [{ "name": "id", "type": "integer" }]
524 }));
525 let out = apply_contract(vec![json!({"id": 1, "surprise": true})], &c).unwrap();
526 assert_eq!(out.warned[0].rule, "extra_field");
527 assert_eq!(out.warned[0].field.as_deref(), Some("surprise"));
528 }
529
530 #[test]
531 fn extra_field_allowed_by_default() {
532 let c = compiled(json!({
533 "version": "1", "on_breach": "warn",
534 "fields": [{ "name": "id", "type": "integer" }]
535 }));
536 let out = apply_contract(vec![json!({"id": 1, "surprise": true})], &c).unwrap();
537 assert!(out.warned.is_empty());
538 }
539
540 #[test]
541 fn non_object_record_breaches() {
542 let out = apply_contract(vec![json!([1, 2, 3])], &basic("warn")).unwrap();
543 assert_eq!(out.warned[0].rule, "not_object");
544 assert!(out.warned[0].field.is_none());
545 }
546
547 #[test]
548 fn first_breach_wins_in_declared_field_order() {
549 let out =
551 apply_contract(vec![json!({"id": "x", "status": "nope"})], &basic("warn")).unwrap();
552 assert_eq!(out.warned.len(), 1, "one breach per record");
553 assert_eq!(out.warned[0].field.as_deref(), Some("id"));
554 }
555
556 #[test]
557 fn range_bounds_inclusive() {
558 let c = compiled(json!({
559 "version": "1", "on_breach": "warn",
560 "fields": [{ "name": "n", "type": "number", "min": 0.0, "max": 10.0 }]
561 }));
562 let out = apply_contract(vec![json!({"n": 0.0}), json!({"n": 10.0})], &c).unwrap();
563 assert!(out.warned.is_empty(), "bounds are inclusive");
564 let out = apply_contract(vec![json!({"n": 10.001})], &c).unwrap();
565 assert_eq!(out.warned[0].rule, "range");
566 }
567
568 #[test]
569 fn violation_describe_includes_field_and_rule() {
570 let v = ContractViolation {
571 rule: "enum",
572 field: Some("status".into()),
573 message: "value \"x\" is not in the allowed set".into(),
574 page_index: 0,
575 };
576 let d = v.describe();
577 assert!(d.contains("field 'status'"));
578 assert!(d.contains("(enum)"));
579 let v2 = ContractViolation {
580 rule: "not_object",
581 field: None,
582 message: "record is not a JSON object (got array)".into(),
583 page_index: 0,
584 };
585 assert!(!v2.describe().contains("field"));
586 }
587}