use crate::CompileError;
const UNREPRESENTABLE: &[(&str, &str)] = &[
("patternProperties", "declare explicit 'properties' instead"),
(
"prefixItems",
"declare a named object; tuples do not round-trip to SQL",
),
(
"$ref",
"inline the definition; cross-schema refs land in a later phase",
),
("allOf", "flatten the composition into one object"),
("anyOf", "split into separate contracts"),
("oneOf", "split into separate contracts"),
("not", "express the constraint positively"),
];
const SUPPORTED_FORMATS: &[&str] = &["email"];
const TOP_LEVEL_KEYWORDS: &[&str] = &[
"$schema",
"$comment",
"type",
"properties",
"required",
"additionalProperties",
];
const PROPERTY_KEYWORDS: &[&str] = &[
"type",
"minLength",
"maxLength",
"format",
"enum",
"default",
"pattern",
];
const SCALAR_KEYWORDS: &[&str] = &["type", "default"];
const ARRAY_KEYWORDS: &[&str] = &["type", "items"];
const ARRAY_ITEM_KEYWORDS: &[&str] = &["type"];
const AUTHORING_EMAIL_PATTERN: &str = r"^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$";
#[derive(Debug, Clone, PartialEq)]
pub enum PropertyKind {
String {
min_length: Option<u64>,
max_length: Option<u64>,
format: Option<String>,
enum_values: Option<Vec<String>>,
default: Option<String>,
},
Integer {
default: Option<i64>,
},
Number {
default: Option<f64>,
},
Boolean {
default: Option<bool>,
},
Array {
element: ArrayElement,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArrayElement {
String,
Integer,
Number,
Boolean,
}
impl PropertyKind {
#[must_use]
pub fn has_default(&self) -> bool {
match self {
Self::String { default, .. } => default.is_some(),
Self::Integer { default } => default.is_some(),
Self::Number { default } => default.is_some(),
Self::Boolean { default } => default.is_some(),
Self::Array { .. } => false,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Property {
pub name: String,
pub kind: PropertyKind,
pub required: bool,
}
#[derive(Debug, Clone)]
pub struct ContractSchema {
pub contract_name: String,
pub properties: Vec<Property>,
}
pub fn parse(contract_name: &str, schema_json: &str) -> Result<ContractSchema, CompileError> {
let value: serde_json::Value =
serde_json::from_str(schema_json).map_err(|error| CompileError::InvalidSchema {
message: error.to_string(),
})?;
require_identifier("contract name", contract_name)?;
reject_unrepresentable(&value)?;
reject_unknown_top_level_keywords(&value)?;
require_strict_object(&value)?;
let required = required_names(&value)?;
let properties = parse_properties(&value, &required)?;
Ok(ContractSchema {
contract_name: contract_name.to_owned(),
properties,
})
}
fn reject_unrepresentable(value: &serde_json::Value) -> Result<(), CompileError> {
match value {
serde_json::Value::Object(map) => {
for (key, child) in map {
if let Some((construct, alternative)) = UNREPRESENTABLE
.iter()
.find(|(construct, _)| *construct == key)
{
return Err(CompileError::Unrepresentable {
construct: (*construct).to_owned(),
alternatives: (*alternative).to_owned(),
});
}
if key == "properties" {
if let Some(properties) = child.as_object() {
for subschema in properties.values() {
reject_unrepresentable(subschema)?;
}
continue;
}
}
reject_unrepresentable(child)?;
}
}
serde_json::Value::Array(entries) => {
for entry in entries {
reject_unrepresentable(entry)?;
}
}
_ => {}
}
Ok(())
}
fn reject_unknown_top_level_keywords(value: &serde_json::Value) -> Result<(), CompileError> {
for key in value.as_object().into_iter().flatten().map(|(key, _)| key) {
if !TOP_LEVEL_KEYWORDS.contains(&key.as_str()) {
return Err(CompileError::Unrepresentable {
construct: format!("top-level keyword '{key}'"),
alternatives: format!(
"the subset carries {}; annotation keywords are not \
emitted to any target, so carrying them would drift \
the bindings — remove it, or propose it as a \
widening step",
TOP_LEVEL_KEYWORDS.join(", ")
),
});
}
}
Ok(())
}
fn reject_unknown_property_keywords(
name: &str,
spec: &serde_json::Value,
) -> Result<(), CompileError> {
for key in spec.as_object().into_iter().flatten().map(|(key, _)| key) {
if !PROPERTY_KEYWORDS.contains(&key.as_str()) {
return Err(CompileError::Unrepresentable {
construct: format!("keyword '{key}' on property '{name}'"),
alternatives: format!(
"the subset carries {}; remove it, or propose it as a \
widening step",
PROPERTY_KEYWORDS.join(", ")
),
});
}
}
Ok(())
}
fn validate_pattern(
name: &str,
spec: &serde_json::Value,
format: Option<&str>,
) -> Result<(), CompileError> {
let Some(value) = spec.get("pattern") else {
return Ok(());
};
let Some(pattern) = value.as_str() else {
return Err(invalid_property_keyword(name, "'pattern' must be a string"));
};
if format == Some("email") && pattern == AUTHORING_EMAIL_PATTERN {
return Ok(());
}
Err(CompileError::Unrepresentable {
construct: format!("'pattern' on property '{name}'"),
alternatives: "format 'email', or propose pattern support as a widening step".to_owned(),
})
}
fn require_strict_object(value: &serde_json::Value) -> Result<(), CompileError> {
if value.get("type").and_then(serde_json::Value::as_str) != Some("object") {
return Err(CompileError::InvalidSchema {
message: "top-level schema must be an object type".to_owned(),
});
}
if value.get("additionalProperties") != Some(&serde_json::Value::Bool(false)) {
return Err(CompileError::InvalidSchema {
message: "additionalProperties must be false (strictness is mandatory, charter N2)"
.to_owned(),
});
}
Ok(())
}
fn required_names(value: &serde_json::Value) -> Result<Vec<String>, CompileError> {
let Some(required) = value.get("required") else {
return Ok(Vec::new());
};
let Some(entries) = required.as_array() else {
return Err(invalid_keyword("required", "must be an array of strings"));
};
entries
.iter()
.map(|entry| {
entry
.as_str()
.map(str::to_owned)
.ok_or_else(|| invalid_keyword("required", "entries must all be strings"))
})
.collect()
}
fn parse_properties(
value: &serde_json::Value,
required: &[String],
) -> Result<Vec<Property>, CompileError> {
let Some(map) = value
.get("properties")
.and_then(serde_json::Value::as_object)
else {
return Err(CompileError::InvalidSchema {
message: "schema declares no properties".to_owned(),
});
};
let mut properties = Vec::new();
for (name, spec) in map {
require_identifier("property name", name)?;
let kind = parse_property(name, spec)?;
let required = required.contains(name);
reject_required_with_default(name, &kind, required)?;
reject_non_required_array(name, &kind, required)?;
properties.push(Property {
name: name.clone(),
kind,
required,
});
}
Ok(properties)
}
fn reject_required_with_default(
name: &str,
kind: &PropertyKind,
required: bool,
) -> Result<(), CompileError> {
if required && kind.has_default() {
return Err(CompileError::InvalidSchema {
message: format!(
"property '{name}' is both required and has a default; \
choose one: required (caller must send it) or \
default (caller may omit it)"
),
});
}
Ok(())
}
fn reject_non_required_array(
name: &str,
kind: &PropertyKind,
required: bool,
) -> Result<(), CompileError> {
if !required && matches!(kind, PropertyKind::Array { .. }) {
return Err(CompileError::InvalidSchema {
message: format!(
"array property '{name}' must be required; mark it required, \
or propose optional arrays as their own widening step"
),
});
}
Ok(())
}
fn require_identifier(role: &str, name: &str) -> Result<(), CompileError> {
let mut chars = name.chars();
let valid = chars
.next()
.is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
&& chars.all(|rest| rest.is_ascii_alphanumeric() || rest == '_');
if valid {
return Ok(());
}
Err(CompileError::InvalidSchema {
message: format!(
"{role} '{name}' is not a portable identifier; \
names must match [A-Za-z_][A-Za-z0-9_]* to emit into all targets"
),
})
}
fn parse_property(name: &str, spec: &serde_json::Value) -> Result<PropertyKind, CompileError> {
match spec.get("type").and_then(serde_json::Value::as_str) {
Some("string") => parse_string_property(name, spec),
Some(scalar @ ("integer" | "number" | "boolean")) => {
parse_scalar_property(name, spec, scalar)
}
Some("array") => parse_array_property(name, spec),
other => Err(CompileError::Unrepresentable {
construct: format!("property '{name}' of type {other:?}"),
alternatives: "the subset carries string, integer, number, and boolean \
properties, and arrays of those scalars; objects land in \
a later widening step"
.to_owned(),
}),
}
}
fn parse_array_property(
name: &str,
spec: &serde_json::Value,
) -> Result<PropertyKind, CompileError> {
for key in spec.as_object().into_iter().flatten().map(|(key, _)| key) {
if !ARRAY_KEYWORDS.contains(&key.as_str()) {
return Err(CompileError::Unrepresentable {
construct: format!("keyword '{key}' on array property '{name}'"),
alternatives: format!(
"the array widening step carries {}; remove it, or propose \
it as a widening step",
ARRAY_KEYWORDS.join(", ")
),
});
}
}
let Some(items) = spec.get("items") else {
return Err(CompileError::Unrepresentable {
construct: format!("array property '{name}' without 'items'"),
alternatives: format!(
"an array needs an element type: {}; write \
{{\"type\": \"array\", \"items\": {{\"type\": \"string\"}}}}",
ARRAY_KEYWORDS.join(", ")
),
});
};
for key in items.as_object().into_iter().flatten().map(|(key, _)| key) {
if !ARRAY_ITEM_KEYWORDS.contains(&key.as_str()) {
return Err(CompileError::Unrepresentable {
construct: format!("keyword '{key}' on the items of array property '{name}'"),
alternatives: format!(
"array items carry {} only — a per-element constraint is not \
a SQL column constraint, so admitting it would enforce the \
contract in three targets and not the fourth",
ARRAY_ITEM_KEYWORDS.join(", ")
),
});
}
}
let element = match items.get("type").and_then(serde_json::Value::as_str) {
Some("string") => ArrayElement::String,
Some("integer") => ArrayElement::Integer,
Some("number") => ArrayElement::Number,
Some("boolean") => ArrayElement::Boolean,
other => {
return Err(CompileError::Unrepresentable {
construct: format!("array property '{name}' with items of type {other:?}"),
alternatives: "array items carry string, integer, number, or boolean; \
nested arrays and arrays of objects land in a later \
widening step"
.to_owned(),
});
}
};
Ok(PropertyKind::Array { element })
}
fn parse_scalar_property(
name: &str,
spec: &serde_json::Value,
scalar: &str,
) -> Result<PropertyKind, CompileError> {
for key in spec.as_object().into_iter().flatten().map(|(key, _)| key) {
if !SCALAR_KEYWORDS.contains(&key.as_str()) {
return Err(CompileError::Unrepresentable {
construct: format!("keyword '{key}' on {scalar} property '{name}'"),
alternatives: format!(
"the scalar widening step carries {}; remove it, or \
propose it as a widening step",
SCALAR_KEYWORDS.join(", ")
),
});
}
}
let default = spec.get("default");
match scalar {
"integer" => {
let parsed = typed_default(name, default, scalar, serde_json::Value::as_i64)?;
if let Some(val) = parsed {
let max_safe = 9_007_199_254_740_991_i64;
let min_safe = -9_007_199_254_740_991_i64;
if val > max_safe || val < min_safe {
return Err(CompileError::InvalidSchema {
message: format!(
"integer default {val} on property '{name}' exceeds the \
safe range for JavaScript number binding (±2^53-1); \
the Zod target would emit a different value"
),
});
}
}
Ok(PropertyKind::Integer { default: parsed })
}
"number" => Ok(PropertyKind::Number {
default: typed_default(name, default, scalar, serde_json::Value::as_f64)?,
}),
_ => Ok(PropertyKind::Boolean {
default: typed_default(name, default, scalar, serde_json::Value::as_bool)?,
}),
}
}
fn typed_default<T>(
name: &str,
value: Option<&serde_json::Value>,
scalar: &str,
extract: impl Fn(&serde_json::Value) -> Option<T>,
) -> Result<Option<T>, CompileError> {
let Some(value) = value else {
return Ok(None);
};
extract(value).map(Some).ok_or_else(|| {
invalid_property_keyword(
name,
&format!("'default' must be a {scalar} for a {scalar} property"),
)
})
}
fn parse_string_property(
name: &str,
spec: &serde_json::Value,
) -> Result<PropertyKind, CompileError> {
let type_name = spec.get("type").and_then(serde_json::Value::as_str);
if type_name != Some("string") {
return Err(CompileError::Unrepresentable {
construct: format!("property '{name}' of type {type_name:?}"),
alternatives: "Phase 1 subset carries string properties; widen in a later phase"
.to_owned(),
});
}
reject_unknown_property_keywords(name, spec)?;
let format = parse_format(name, spec)?;
validate_pattern(name, spec, format.as_deref())?;
let enum_values = parse_enum(name, spec)?;
let default = parse_default(name, spec)?;
if let (Some(values), Some(value)) = (&enum_values, &default) {
if !values.contains(value) {
return Err(invalid_property_keyword(
name,
"'default' must be one of the declared 'enum' values",
));
}
}
Ok(PropertyKind::String {
min_length: spec.get("minLength").and_then(serde_json::Value::as_u64),
max_length: spec.get("maxLength").and_then(serde_json::Value::as_u64),
format,
enum_values,
default,
})
}
fn parse_format(name: &str, spec: &serde_json::Value) -> Result<Option<String>, CompileError> {
let Some(value) = spec.get("format") else {
return Ok(None);
};
let Some(format) = value.as_str() else {
return Err(invalid_keyword("format", "must be a string"));
};
if !SUPPORTED_FORMATS.contains(&format) {
return Err(CompileError::Unrepresentable {
construct: format!("format '{format}' on property '{name}'"),
alternatives: "format 'email', or omit 'format'".to_owned(),
});
}
Ok(Some(format.to_owned()))
}
fn parse_enum(name: &str, spec: &serde_json::Value) -> Result<Option<Vec<String>>, CompileError> {
let Some(value) = spec.get("enum") else {
return Ok(None);
};
let Some(entries) = value.as_array() else {
return Err(invalid_property_keyword(name, "'enum' must be an array"));
};
entries
.iter()
.map(|entry| {
entry
.as_str()
.map(str::to_owned)
.ok_or_else(|| invalid_property_keyword(name, "'enum' entries must all be strings"))
})
.collect::<Result<Vec<_>, _>>()
.map(Some)
}
fn parse_default(name: &str, spec: &serde_json::Value) -> Result<Option<String>, CompileError> {
let Some(value) = spec.get("default") else {
return Ok(None);
};
value.as_str().map(str::to_owned).map(Some).ok_or_else(|| {
invalid_property_keyword(name, "'default' must be a string for a string property")
})
}
fn invalid_keyword(keyword: &str, detail: &str) -> CompileError {
CompileError::InvalidSchema {
message: format!("'{keyword}' {detail}"),
}
}
fn invalid_property_keyword(name: &str, detail: &str) -> CompileError {
CompileError::InvalidSchema {
message: format!("property '{name}' keyword {detail}"),
}
}