Skip to main content

faucet_core/contract/
mod.rs

1//! Data contracts (issue #204): a declarative, versioned schema + semantic
2//! contract for a pipeline's output — required fields, types, nullability,
3//! enum sets, regex patterns, numeric/length bounds — enforced per page after
4//! transforms and quality checks and before the sink write. Breaches `fail`,
5//! `quarantine` (via the DLQ), or `warn` per the contract-level policy.
6//!
7//! Pure evaluation; the pipeline wires DLQ routing and metrics in
8//! `run_stream` / `instrumented_apply_contract`.
9
10pub 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/// One observed contract breach.
23#[derive(Debug, Clone)]
24pub struct ContractViolation {
25    /// Stable rule label (closed set, safe as a metric label): `not_object`,
26    /// `missing`, `null`, `type`, `enum`, `pattern`, `range`, `length`,
27    /// `extra_field`.
28    pub rule: &'static str,
29    /// Breaching field name; `None` for whole-record rules (`not_object`).
30    pub field: Option<String>,
31    /// Human-readable breach message (goes into errors / DLQ envelopes).
32    pub message: String,
33    /// 0-based position within the original page — the DLQ envelope's
34    /// `record_index` contract (position within the page that failed).
35    pub page_index: usize,
36}
37
38impl ContractViolation {
39    /// One-line human description: `field 'x': <message> (<rule>)`.
40    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/// A record removed from the page by a `quarantine` breach, destined for the
49/// DLQ.
50#[derive(Debug, Clone)]
51pub struct ViolatingRecord {
52    pub record: Value,
53    pub violation: ContractViolation,
54}
55
56/// Result of applying the contract pass to one page.
57#[derive(Debug, Clone, Default)]
58pub struct ContractOutcome {
59    /// Records that conform (plus, under `warn`, records that breached).
60    pub survivors: Vec<Value>,
61    /// Breaching records routed to the DLQ (`quarantine` only).
62    pub quarantined: Vec<ViolatingRecord>,
63    /// Breaches observed under `warn` (their records stay in `survivors`).
64    pub warned: Vec<ContractViolation>,
65}
66
67/// Apply the contract to one page. Pure: no metrics, no DLQ I/O.
68///
69/// Records are evaluated in order; per record, the first breach wins (fields
70/// in declared order, then the extra-field check). Policy:
71/// - `fail` — return [`FaucetError::ContractViolation`] on the first breach.
72/// - `quarantine` — move breaching records to `quarantined`.
73/// - `warn` — record the breach in `warned`, keep the record in `survivors`.
74pub 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
103/// Evaluate one record against the contract. Returns the first breach, or
104/// `None` when the record conforms.
105fn 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; // absent optional field: nothing else to check
136            }
137            Some(v) => v,
138        };
139        if value.is_null() {
140            if f.nullable {
141                continue; // explicit null allowed: skip the value checks
142            }
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            // compile() guarantees pattern ⇒ string type, checked above.
167            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"}), // optional email absent
275            json!({"id": 3, "status": "open", "email": null}), // nullable
276        ];
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"}), // range breach at index 1
305            json!({"id": 3, "status": "open"}),
306            json!({"id": 4, "status": "nope"}), // enum breach at index 3
307        ];
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        // "@b" fails min_length 3? len 2 < 3 → but pattern checked first: "@b"
361        // does not match ^.+@.+$ either. Pattern wins (declared order of rules).
362        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        // char count, not byte count
379        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        // Both `id` (type) and `status` (enum) breach; `id` is declared first.
421        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}