use serde_json::Value;
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq)]
pub enum Schema {
Primitive(Primitive),
Array(Box<Schema>),
Tuple(Vec<Schema>),
Object(ObjectSchema),
Enum(Vec<Value>),
Const(Value),
Union(Vec<Schema>),
Intersection(Vec<Schema>),
Ref(String),
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Primitive {
String,
Integer,
Number,
Boolean,
Null,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ObjectSchema {
pub properties: Vec<(String, Schema)>,
pub required: BTreeSet<String>,
pub additional: Additional,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Additional {
Unspecified,
Denied,
Allowed,
Typed(Box<Schema>),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SchemaError {
#[error("schema is not valid JSON: {0}")]
NotJson(String),
#[error("unsupported JSON Schema construct: {0}")]
Unsupported(String),
#[error("external $ref not supported (needs network fetch): {0}")]
ExternalRef(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedSchema {
pub root: Schema,
pub defs: Vec<(String, Schema)>,
pub doc: Option<String>,
}
pub fn parse(schema_json: &str) -> Result<ParsedSchema, SchemaError> {
let value: Value =
serde_json::from_str(schema_json).map_err(|e| SchemaError::NotJson(e.to_string()))?;
let root = parse_node(&value)?;
let doc = string_field(&value, "description").or_else(|| string_field(&value, "title"));
let mut defs = Vec::new();
for key in ["$defs", "definitions"] {
if let Some(Value::Object(map)) = value.get(key) {
for (name, def) in map {
defs.push((name.clone(), parse_node(def)?));
}
}
}
Ok(ParsedSchema { root, defs, doc })
}
pub fn parse_node(value: &Value) -> Result<Schema, SchemaError> {
let obj = match value {
Value::Bool(_) => return Ok(Schema::Unknown),
Value::Object(map) => map,
other => {
return Err(SchemaError::Unsupported(format!(
"schema node must be an object or boolean, got {other}"
)))
}
};
for unsupported in [
"not",
"if",
"then",
"else",
"dependentSchemas",
"dependentRequired",
"unevaluatedProperties",
] {
if obj.contains_key(unsupported) {
return Err(SchemaError::Unsupported(unsupported.to_string()));
}
}
if let Some(Value::String(r)) = obj.get("$ref") {
return parse_ref(r);
}
if let Some(c) = obj.get("const") {
return Ok(Schema::Const(c.clone()));
}
if let Some(Value::Array(values)) = obj.get("enum") {
return Ok(Schema::Enum(values.clone()));
}
if let Some(Value::Array(branches)) = obj.get("oneOf").or_else(|| obj.get("anyOf")) {
let parsed = branches.iter().map(parse_node).collect::<Result<_, _>>()?;
return Ok(Schema::Union(parsed));
}
if let Some(Value::Array(parts)) = obj.get("allOf") {
let parsed = parts.iter().map(parse_node).collect::<Result<_, _>>()?;
return Ok(Schema::Intersection(parsed));
}
let nullable = matches!(obj.get("nullable"), Some(Value::Bool(true)));
let base = parse_typed(obj)?;
if nullable {
return Ok(union_with_null(base));
}
Ok(base)
}
fn parse_typed(obj: &serde_json::Map<String, Value>) -> Result<Schema, SchemaError> {
match obj.get("type") {
Some(Value::Array(types)) => {
let mut branches = Vec::with_capacity(types.len());
for t in types {
let name = t.as_str().ok_or_else(|| {
SchemaError::Unsupported("non-string entry in `type` array".into())
})?;
branches.push(Schema::Primitive(primitive(name)?));
}
Ok(Schema::Union(branches))
}
Some(Value::String(t)) => match t.as_str() {
"object" => Ok(Schema::Object(parse_object(obj)?)),
"array" => parse_array(obj),
scalar => Ok(Schema::Primitive(primitive(scalar)?)),
},
None if obj.contains_key("properties") => Ok(Schema::Object(parse_object(obj)?)),
None => Ok(Schema::Unknown),
Some(other) => Err(SchemaError::Unsupported(format!(
"`type` must be a string or array of strings, got {other}"
))),
}
}
fn parse_object(obj: &serde_json::Map<String, Value>) -> Result<ObjectSchema, SchemaError> {
let mut properties = Vec::new();
if let Some(Value::Object(props)) = obj.get("properties") {
for (name, prop) in props {
properties.push((name.clone(), parse_node(prop)?));
}
}
let required = match obj.get("required") {
Some(Value::Array(names)) => names
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
_ => BTreeSet::new(),
};
let additional = match obj.get("additionalProperties") {
None => Additional::Unspecified,
Some(Value::Bool(true)) => Additional::Allowed,
Some(Value::Bool(false)) => Additional::Denied,
Some(schema) => Additional::Typed(Box::new(parse_node(schema)?)),
};
Ok(ObjectSchema {
properties,
required,
additional,
})
}
fn parse_array(obj: &serde_json::Map<String, Value>) -> Result<Schema, SchemaError> {
if let Some(Value::Array(prefix)) = obj.get("prefixItems") {
let items = prefix.iter().map(parse_node).collect::<Result<_, _>>()?;
return Ok(Schema::Tuple(items));
}
match obj.get("items") {
Some(items) => Ok(Schema::Array(Box::new(parse_node(items)?))),
None => Ok(Schema::Array(Box::new(Schema::Unknown))),
}
}
fn parse_ref(r: &str) -> Result<Schema, SchemaError> {
for prefix in ["#/$defs/", "#/definitions/"] {
if let Some(name) = r.strip_prefix(prefix) {
return Ok(Schema::Ref(name.to_string()));
}
}
Err(SchemaError::ExternalRef(r.to_string()))
}
fn primitive(name: &str) -> Result<Primitive, SchemaError> {
Ok(match name {
"string" => Primitive::String,
"integer" => Primitive::Integer,
"number" => Primitive::Number,
"boolean" => Primitive::Boolean,
"null" => Primitive::Null,
other => return Err(SchemaError::Unsupported(format!("type `{other}`"))),
})
}
fn union_with_null(schema: Schema) -> Schema {
match schema {
Schema::Union(mut branches) => {
if !branches.contains(&Schema::Primitive(Primitive::Null)) {
branches.push(Schema::Primitive(Primitive::Null));
}
Schema::Union(branches)
}
other => Schema::Union(vec![other, Schema::Primitive(Primitive::Null)]),
}
}
fn string_field(value: &Value, key: &str) -> Option<String> {
value.get(key).and_then(Value::as_str).map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
fn p(json: &str) -> Schema {
parse(json).expect("parse").root
}
#[test]
fn primitives_and_arrays() {
assert_eq!(
p(r#"{"type":"string"}"#),
Schema::Primitive(Primitive::String)
);
assert_eq!(
p(r#"{"type":"array","items":{"type":"integer"}}"#),
Schema::Array(Box::new(Schema::Primitive(Primitive::Integer)))
);
}
#[test]
fn object_required_and_additional() {
let s = p(
r#"{"type":"object","properties":{"a":{"type":"string"},"b":{"type":"integer"}},"required":["a"],"additionalProperties":false}"#,
);
let Schema::Object(o) = s else {
panic!("expected object")
};
assert_eq!(o.properties.len(), 2);
assert_eq!(o.properties[0].0, "a"); assert!(o.required.contains("a") && !o.required.contains("b"));
assert_eq!(o.additional, Additional::Denied);
}
#[test]
fn enum_const_union_intersection() {
assert!(matches!(p(r#"{"enum":["a","b"]}"#), Schema::Enum(v) if v.len() == 2));
assert!(matches!(p(r#"{"const":"x"}"#), Schema::Const(_)));
assert!(matches!(
p(r#"{"oneOf":[{"type":"string"},{"type":"integer"}]}"#),
Schema::Union(v) if v.len() == 2
));
assert!(matches!(
p(r#"{"allOf":[{"type":"object"},{"type":"object"}]}"#),
Schema::Intersection(v) if v.len() == 2
));
}
#[test]
fn nullable_and_type_array() {
assert_eq!(
p(r#"{"type":"string","nullable":true}"#),
Schema::Union(vec![
Schema::Primitive(Primitive::String),
Schema::Primitive(Primitive::Null)
])
);
assert_eq!(
p(r#"{"type":["string","null"]}"#),
Schema::Union(vec![
Schema::Primitive(Primitive::String),
Schema::Primitive(Primitive::Null)
])
);
}
#[test]
fn local_ref_and_defs() {
let parsed = parse(
r##"{"type":"object","properties":{"child":{"$ref":"#/$defs/Child"}},"$defs":{"Child":{"type":"object","properties":{"x":{"type":"number"}}}}}"##,
)
.expect("parse");
let Schema::Object(o) = &parsed.root else {
panic!()
};
assert_eq!(o.properties[0].1, Schema::Ref("Child".into()));
assert_eq!(parsed.defs.len(), 1);
assert_eq!(parsed.defs[0].0, "Child");
}
#[test]
fn unsupported_constructs_error() {
assert!(matches!(
parse(r#"{"not":{"type":"string"}}"#),
Err(SchemaError::Unsupported(_))
));
assert!(matches!(
parse(r#"{"$ref":"https://example.com/schema.json"}"#),
Err(SchemaError::ExternalRef(_))
));
assert!(matches!(parse("not json"), Err(SchemaError::NotJson(_))));
}
#[test]
fn empty_schema_is_unknown() {
assert_eq!(p("{}"), Schema::Unknown);
assert_eq!(p("true"), Schema::Unknown);
}
}