Skip to main content

faucet_core/contract/
config.rs

1//! Config-shaped types for the data-contract layer (issue #204). Pure
2//! declarations — validation and compilation live in `compile.rs`, evaluation
3//! in the module root.
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9fn default_true() -> bool {
10    true
11}
12
13/// What to do when a record breaches the contract. Contract-level (not
14/// per-field): a contract is a single versioned promise, so one policy
15/// governs every rule in it.
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum OnBreach {
19    /// Surface [`FaucetError::ContractViolation`](crate::FaucetError::ContractViolation)
20    /// on the first breach and fail the run (default — a contract is a
21    /// promise, so silent divergence is the worst outcome).
22    #[default]
23    Fail,
24    /// Route breaching records to the DLQ; write the rest. Requires a DLQ.
25    Quarantine,
26    /// Log + count breaches, but write every record unchanged.
27    Warn,
28}
29
30impl std::fmt::Display for OnBreach {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        f.write_str(match self {
33            OnBreach::Fail => "fail",
34            OnBreach::Quarantine => "quarantine",
35            OnBreach::Warn => "warn",
36        })
37    }
38}
39
40/// Declared type of a contract field.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
42#[serde(rename_all = "snake_case")]
43pub enum ContractFieldType {
44    /// JSON string.
45    String,
46    /// JSON number with no fractional part (fits `i64`/`u64` as parsed).
47    Integer,
48    /// Any JSON number (integer or float).
49    Number,
50    /// JSON boolean.
51    Boolean,
52    /// JSON object. Nested shapes are treated as one opaque column — a
53    /// contract describes the top-level output shape, matching the
54    /// schema-drift convention.
55    Object,
56    /// JSON array.
57    Array,
58}
59
60impl std::fmt::Display for ContractFieldType {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.write_str(match self {
63            ContractFieldType::String => "string",
64            ContractFieldType::Integer => "integer",
65            ContractFieldType::Number => "number",
66            ContractFieldType::Boolean => "boolean",
67            ContractFieldType::Object => "object",
68            ContractFieldType::Array => "array",
69        })
70    }
71}
72
73/// The `contract:` config block: a declarative, versioned schema + semantic
74/// contract for a pipeline's output, enforced per page after transforms and
75/// quality checks and before the sink write.
76#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
77#[serde(deny_unknown_fields)]
78pub struct ContractSpec {
79    /// Contract version, e.g. `"1.0.0"`. Required and non-empty. Carried
80    /// into breach errors, DLQ envelopes, and every export format so
81    /// producers and consumers can pin the promise they agreed on.
82    /// Recommendation: bump the major version on breaking changes (removing
83    /// a field, narrowing a type) and the minor version on additive ones.
84    pub version: String,
85
86    /// Human-readable description of the dataset this contract covers.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub description: Option<String>,
89
90    /// Owning team or person (documentation metadata; carried into exports).
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub owner: Option<String>,
93
94    /// Breach policy: `fail` (default) aborts the run, `quarantine` routes
95    /// breaching records to the DLQ (a `dlq:` block is required), `warn`
96    /// logs + counts but writes everything.
97    #[serde(default)]
98    pub on_breach: OnBreach,
99
100    /// When `false`, a top-level key not declared in `fields` is a breach
101    /// (`extra_field`). Default `true` — additive fields are allowed.
102    #[serde(default = "default_true")]
103    pub allow_extra_fields: bool,
104
105    /// The promised top-level fields. Must be non-empty; names must be unique.
106    pub fields: Vec<FieldContract>,
107}
108
109/// One promised top-level field: its type plus optional semantic constraints.
110#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
111#[serde(deny_unknown_fields)]
112pub struct FieldContract {
113    /// Top-level field name (contracts describe the output's top-level shape;
114    /// nested objects are typed as `object`).
115    pub name: String,
116
117    /// Declared type. `integer` means a JSON number with no fractional part;
118    /// `number` accepts any JSON number.
119    #[serde(rename = "type")]
120    pub field_type: ContractFieldType,
121
122    /// When `true` (default) the field must be present in every record.
123    /// A non-required field that is absent skips all other checks.
124    #[serde(default = "default_true")]
125    pub required: bool,
126
127    /// When `true`, an explicit JSON `null` is allowed (and skips the
128    /// type/constraint checks). Default `false`.
129    #[serde(default)]
130    pub nullable: bool,
131
132    /// Allowed values (exact JSON equality). Must be non-empty when present,
133    /// and every value must match the declared `type`. Use `nullable` for
134    /// null — `null` is not allowed inside `enum`.
135    #[serde(rename = "enum", default, skip_serializing_if = "Option::is_none")]
136    pub allowed_values: Option<Vec<Value>>,
137
138    /// Regex the value must match. `string` fields only.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub pattern: Option<String>,
141
142    /// Minimum numeric value (inclusive). `integer`/`number` fields only.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub min: Option<f64>,
145
146    /// Maximum numeric value (inclusive). `integer`/`number` fields only.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub max: Option<f64>,
149
150    /// Minimum string length in characters (inclusive). `string` fields only.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub min_length: Option<usize>,
153
154    /// Maximum string length in characters (inclusive). `string` fields only.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub max_length: Option<usize>,
157
158    /// Column description (documentation metadata; carried into exports).
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub description: Option<String>,
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use serde_json::json;
167
168    #[test]
169    fn on_breach_defaults_to_fail() {
170        assert_eq!(OnBreach::default(), OnBreach::Fail);
171    }
172
173    #[test]
174    fn on_breach_serializes_snake_case() {
175        assert_eq!(
176            serde_json::to_string(&OnBreach::Quarantine).unwrap(),
177            "\"quarantine\""
178        );
179        let w: OnBreach = serde_json::from_str("\"warn\"").unwrap();
180        assert_eq!(w, OnBreach::Warn);
181    }
182
183    #[test]
184    fn field_type_round_trips() {
185        let t: ContractFieldType = serde_json::from_str("\"integer\"").unwrap();
186        assert_eq!(t, ContractFieldType::Integer);
187        assert_eq!(t.to_string(), "integer");
188    }
189
190    #[test]
191    fn parses_full_contract_block() {
192        let spec: ContractSpec = serde_json::from_value(json!({
193            "version": "1.2.0",
194            "description": "orders output",
195            "owner": "data-platform",
196            "on_breach": "quarantine",
197            "allow_extra_fields": false,
198            "fields": [
199                { "name": "id", "type": "integer", "min": 1 },
200                { "name": "status", "type": "string",
201                  "enum": ["open", "closed"], "description": "lifecycle state" },
202                { "name": "email", "type": "string", "pattern": "^.+@.+$",
203                  "required": false, "nullable": true,
204                  "min_length": 3, "max_length": 254 }
205            ]
206        }))
207        .unwrap();
208        assert_eq!(spec.version, "1.2.0");
209        assert_eq!(spec.on_breach, OnBreach::Quarantine);
210        assert!(!spec.allow_extra_fields);
211        assert_eq!(spec.fields.len(), 3);
212        assert!(spec.fields[0].required, "required defaults to true");
213        assert!(!spec.fields[0].nullable, "nullable defaults to false");
214        assert!(!spec.fields[2].required);
215        assert!(spec.fields[2].nullable);
216        assert_eq!(
217            spec.fields[1].allowed_values.as_ref().unwrap(),
218            &vec![json!("open"), json!("closed")]
219        );
220    }
221
222    #[test]
223    fn rejects_unknown_keys() {
224        let err = serde_json::from_value::<ContractSpec>(json!({
225            "version": "1", "fields": [], "nope": true
226        }));
227        assert!(err.is_err());
228    }
229
230    #[test]
231    fn defaults_when_optional_fields_absent() {
232        let spec: ContractSpec = serde_json::from_value(json!({
233            "version": "1.0.0",
234            "fields": [{ "name": "id", "type": "string" }]
235        }))
236        .unwrap();
237        assert_eq!(spec.on_breach, OnBreach::Fail);
238        assert!(spec.allow_extra_fields);
239        assert!(spec.description.is_none());
240        assert!(spec.owner.is_none());
241    }
242}