use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum OnBreach {
#[default]
Fail,
Quarantine,
Warn,
}
impl std::fmt::Display for OnBreach {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
OnBreach::Fail => "fail",
OnBreach::Quarantine => "quarantine",
OnBreach::Warn => "warn",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ContractFieldType {
String,
Integer,
Number,
Boolean,
Object,
Array,
}
impl std::fmt::Display for ContractFieldType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ContractFieldType::String => "string",
ContractFieldType::Integer => "integer",
ContractFieldType::Number => "number",
ContractFieldType::Boolean => "boolean",
ContractFieldType::Object => "object",
ContractFieldType::Array => "array",
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ContractSpec {
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default)]
pub on_breach: OnBreach,
#[serde(default = "default_true")]
pub allow_extra_fields: bool,
pub fields: Vec<FieldContract>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct FieldContract {
pub name: String,
#[serde(rename = "type")]
pub field_type: ContractFieldType,
#[serde(default = "default_true")]
pub required: bool,
#[serde(default)]
pub nullable: bool,
#[serde(rename = "enum", default, skip_serializing_if = "Option::is_none")]
pub allowed_values: Option<Vec<Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_length: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_length: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn on_breach_defaults_to_fail() {
assert_eq!(OnBreach::default(), OnBreach::Fail);
}
#[test]
fn on_breach_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&OnBreach::Quarantine).unwrap(),
"\"quarantine\""
);
let w: OnBreach = serde_json::from_str("\"warn\"").unwrap();
assert_eq!(w, OnBreach::Warn);
}
#[test]
fn field_type_round_trips() {
let t: ContractFieldType = serde_json::from_str("\"integer\"").unwrap();
assert_eq!(t, ContractFieldType::Integer);
assert_eq!(t.to_string(), "integer");
}
#[test]
fn parses_full_contract_block() {
let spec: ContractSpec = serde_json::from_value(json!({
"version": "1.2.0",
"description": "orders output",
"owner": "data-platform",
"on_breach": "quarantine",
"allow_extra_fields": false,
"fields": [
{ "name": "id", "type": "integer", "min": 1 },
{ "name": "status", "type": "string",
"enum": ["open", "closed"], "description": "lifecycle state" },
{ "name": "email", "type": "string", "pattern": "^.+@.+$",
"required": false, "nullable": true,
"min_length": 3, "max_length": 254 }
]
}))
.unwrap();
assert_eq!(spec.version, "1.2.0");
assert_eq!(spec.on_breach, OnBreach::Quarantine);
assert!(!spec.allow_extra_fields);
assert_eq!(spec.fields.len(), 3);
assert!(spec.fields[0].required, "required defaults to true");
assert!(!spec.fields[0].nullable, "nullable defaults to false");
assert!(!spec.fields[2].required);
assert!(spec.fields[2].nullable);
assert_eq!(
spec.fields[1].allowed_values.as_ref().unwrap(),
&vec![json!("open"), json!("closed")]
);
}
#[test]
fn rejects_unknown_keys() {
let err = serde_json::from_value::<ContractSpec>(json!({
"version": "1", "fields": [], "nope": true
}));
assert!(err.is_err());
}
#[test]
fn defaults_when_optional_fields_absent() {
let spec: ContractSpec = serde_json::from_value(json!({
"version": "1.0.0",
"fields": [{ "name": "id", "type": "string" }]
}))
.unwrap();
assert_eq!(spec.on_breach, OnBreach::Fail);
assert!(spec.allow_extra_fields);
assert!(spec.description.is_none());
assert!(spec.owner.is_none());
}
}