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 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>,
},
}
#[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_string_property(name, spec)?;
let required = required.contains(name);
reject_required_with_default(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> {
let PropertyKind::String { default, .. } = kind;
if required && default.is_some() {
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 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_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}"),
}
}