use std::collections::BTreeMap;
use serde_json::Value;
use thiserror::Error;
const KNOWN_KEYWORDS: &[&str] = &[
"type",
"description",
"title",
"$ref",
"properties",
"required",
"additionalProperties",
"items",
"anyOf",
"allOf",
"const",
"enum",
"format",
"minimum",
"maximum",
"minItems",
"maxItems",
"minLength",
"maxLength",
"default",
"$schema",
"$defs",
"definitions",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TypeSet {
One(String),
Many(Vec<String>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DefRef(pub String);
#[derive(Debug, Clone, Default)]
pub struct Schema {
pub type_: Option<TypeSet>,
pub ref_: Option<DefRef>,
pub properties: BTreeMap<String, Box<Self>>,
pub required: Vec<String>,
pub additional_properties: Option<Box<Self>>,
pub additional_properties_true: bool,
pub items: Option<Box<Self>>,
pub union: Option<Union>,
pub intersect: Vec<Box<Self>>,
pub constant: Option<Value>,
pub enum_values: Option<Vec<Value>>,
pub format: Option<String>,
pub default: Option<Value>,
pub minimum: Option<f64>,
pub maximum: Option<f64>,
pub min_items: Option<u64>,
pub max_items: Option<u64>,
pub min_length: Option<u64>,
pub max_length: Option<u64>,
pub description: Option<String>,
pub title: Option<String>,
}
impl Schema {
pub fn is_permissive(&self) -> bool {
self.type_.is_none()
&& self.ref_.is_none()
&& self.properties.is_empty()
&& self.items.is_none()
&& self.union.is_none()
&& self.intersect.is_empty()
&& self.constant.is_none()
&& self.enum_values.is_none()
}
pub fn is_structured_object(&self) -> bool {
!self.properties.is_empty() && self.ref_.is_none() && self.union.is_none()
}
pub fn def_ref(&self) -> Option<&str> {
self.ref_.as_ref().map(|r| r.0.as_str())
}
}
#[derive(Debug, Clone)]
pub struct Union {
pub kind: UnionKind,
pub members: Vec<Box<Schema>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnionKind {
AnyOf,
OneOf,
}
#[derive(Debug, Clone)]
pub struct SchemaFile {
pub revision: String,
pub dialect: String,
pub ref_container: RefContainer,
pub defs: BTreeMap<String, Schema>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefContainer {
Definitions,
Defs,
}
impl RefContainer {
pub const fn keyword(self) -> &'static str {
match self {
Self::Definitions => "definitions",
Self::Defs => "$defs",
}
}
}
#[derive(Debug, Error)]
pub enum SchemaParseError {
#[error("{revision}: schema.json is not valid JSON: {source}")]
MalformedJson {
revision: String,
#[source]
source: serde_json::Error,
},
#[error("{revision}: schema.json is not a JSON object")]
NotAnObject {
revision: String,
},
#[error("{revision}: schema.json declares no $schema, so its dialect is unknown")]
MissingDialect {
revision: String,
},
#[error(
"{revision}: schema.json has neither $defs nor definitions, so there is nothing to generate"
)]
NoDefinitions {
revision: String,
},
#[error("{revision}: unsupported schema keyword {keyword:?} at {path}")]
UnsupportedKeyword {
revision: String,
keyword: String,
path: String,
},
#[error("{revision}: unsupported $ref {reference:?} at {path} (expected #{container}/...)")]
UnsupportedRef {
revision: String,
reference: String,
container: &'static str,
path: String,
},
#[error("{revision}: {path} has a non-string `type`")]
BadType {
revision: String,
path: String,
},
#[error("{revision}: {keyword} at {path} is not a {expected}")]
BadKeywordShape {
revision: String,
keyword: String,
path: String,
expected: &'static str,
},
}
impl SchemaParseError {
fn at(revision: &str, path: &str) -> String {
format!("{revision}:{path}")
}
}
pub fn parse(revision: &str, text: &str) -> Result<SchemaFile, SchemaParseError> {
let root: Value =
serde_json::from_str(text).map_err(|source| SchemaParseError::MalformedJson {
revision: revision.to_owned(),
source,
})?;
let Value::Object(root_map) = &root else {
return Err(SchemaParseError::NotAnObject {
revision: revision.to_owned(),
});
};
let dialect = root_map
.get("$schema")
.and_then(Value::as_str)
.ok_or_else(|| SchemaParseError::MissingDialect {
revision: revision.to_owned(),
})?
.to_owned();
let (ref_container, defs_value) = match (root_map.get("$defs"), root_map.get("definitions")) {
(Some(v), None) => (RefContainer::Defs, v),
(None, Some(v)) => (RefContainer::Definitions, v),
(Some(_), Some(_)) | (None, None) => {
return Err(SchemaParseError::NoDefinitions {
revision: revision.to_owned(),
});
}
};
let defs_map = defs_value
.as_object()
.ok_or_else(|| SchemaParseError::BadKeywordShape {
revision: revision.to_owned(),
keyword: ref_container.keyword().to_owned(),
path: SchemaParseError::at(revision, "$"),
expected: "object",
})?;
let mut defs = BTreeMap::new();
for (name, value) in defs_map {
let path = format!("{}/{name}", ref_container.keyword());
defs.insert(
name.clone(),
parse_node(revision, value, &path, ref_container)?,
);
}
Ok(SchemaFile {
revision: revision.to_owned(),
dialect,
ref_container,
defs,
})
}
fn parse_node(
revision: &str,
node: &Value,
path: &str,
container: RefContainer,
) -> Result<Schema, SchemaParseError> {
let Value::Object(map) = node else {
return Err(SchemaParseError::BadKeywordShape {
revision: revision.to_owned(),
keyword: "<schema>".to_owned(),
path: path.to_owned(),
expected: "object",
});
};
reject_unknown_keywords(revision, map, path)?;
let mut out = parse_advisory(map);
if let Some(v) = map.get("type") {
out.type_ = Some(match v {
Value::String(s) => TypeSet::One(s.clone()),
Value::Array(items) => {
let mut names = Vec::with_capacity(items.len());
for item in items {
let Some(s) = item.as_str() else {
return Err(SchemaParseError::BadType {
revision: revision.to_owned(),
path: path.to_owned(),
});
};
names.push(s.to_owned());
}
TypeSet::Many(names)
}
_ => {
return Err(SchemaParseError::BadType {
revision: revision.to_owned(),
path: path.to_owned(),
});
}
});
}
if let Some(r) = map.get("$ref") {
out.ref_ = Some(parse_ref(revision, r, path, container)?);
}
if let Some(Value::Object(props)) = map.get("properties") {
for (name, value) in props {
let child_path = format!("{path}/properties/{name}");
let child = parse_node(revision, value, &child_path, container)?;
out.properties.insert(name.clone(), Box::new(child));
}
}
if let Some(Value::Array(req)) = map.get("required") {
for r in req {
if let Some(s) = r.as_str() {
out.required.push(s.to_owned());
}
}
}
match map.get("additionalProperties") {
Some(Value::Bool(true)) => out.additional_properties_true = true,
Some(Value::Bool(false)) => {
out.additional_properties_true = false;
}
Some(other) => {
let child_path = format!("{path}/additionalProperties");
out.additional_properties = Some(Box::new(parse_node(
revision,
other,
&child_path,
container,
)?));
}
None => {}
}
if let Some(items) = map.get("items") {
out.items = Some(Box::new(parse_node(
revision,
items,
&format!("{path}/items"),
container,
)?));
}
parse_combinators(revision, map, path, container, &mut out)?;
Ok(out)
}
fn parse_combinators(
revision: &str,
map: &serde_json::Map<String, Value>,
path: &str,
container: RefContainer,
out: &mut Schema,
) -> Result<(), SchemaParseError> {
for (keyword, kind) in [("anyOf", UnionKind::AnyOf), ("oneOf", UnionKind::OneOf)] {
if let Some(Value::Array(members)) = map.get(keyword) {
let mut parsed = Vec::with_capacity(members.len());
for (i, m) in members.iter().enumerate() {
parsed.push(Box::new(parse_node(
revision,
m,
&format!("{path}/{keyword}/{i}"),
container,
)?));
}
out.union = Some(Union {
kind,
members: parsed,
});
}
}
if let Some(Value::Array(members)) = map.get("allOf") {
for (i, m) in members.iter().enumerate() {
out.intersect.push(Box::new(parse_node(
revision,
m,
&format!("{path}/allOf/{i}"),
container,
)?));
}
}
if let Some(Value::Array(values)) = map.get("enum") {
out.enum_values = Some(values.clone());
}
Ok(())
}
fn reject_unknown_keywords(
revision: &str,
map: &serde_json::Map<String, Value>,
path: &str,
) -> Result<(), SchemaParseError> {
for key in map.keys() {
if !KNOWN_KEYWORDS.contains(&key.as_str()) {
return Err(SchemaParseError::UnsupportedKeyword {
revision: revision.to_owned(),
keyword: key.clone(),
path: path.to_owned(),
});
}
}
Ok(())
}
fn parse_advisory(map: &serde_json::Map<String, Value>) -> Schema {
Schema {
description: map
.get("description")
.and_then(Value::as_str)
.map(str::to_owned),
title: map.get("title").and_then(Value::as_str).map(str::to_owned),
format: map.get("format").and_then(Value::as_str).map(str::to_owned),
default: map.get("default").cloned(),
minimum: map.get("minimum").and_then(Value::as_f64),
maximum: map.get("maximum").and_then(Value::as_f64),
min_items: map.get("minItems").and_then(Value::as_u64),
max_items: map.get("maxItems").and_then(Value::as_u64),
min_length: map.get("minLength").and_then(Value::as_u64),
max_length: map.get("maxLength").and_then(Value::as_u64),
constant: map.get("const").cloned(),
..Schema::default()
}
}
fn parse_ref(
revision: &str,
value: &Value,
path: &str,
container: RefContainer,
) -> Result<DefRef, SchemaParseError> {
let unsupported = || SchemaParseError::UnsupportedRef {
revision: revision.to_owned(),
reference: value.as_str().unwrap_or("<non-string>").to_owned(),
container: container.keyword(),
path: path.to_owned(),
};
let Some(text) = value.as_str() else {
return Err(unsupported());
};
let prefix = match container {
RefContainer::Defs => "#/$defs/",
RefContainer::Definitions => "#/definitions/",
};
let Some(name) = text.strip_prefix(prefix) else {
return Err(unsupported());
};
if name.is_empty() || name.contains('/') {
return Err(unsupported());
}
Ok(DefRef(name.to_owned()))
}