parse composite schema constraints

This commit is contained in:
Adam Perry
2026-06-25 14:41:02 +00:00
parent 5a52d27e51
commit bf8f6e7452
8 changed files with 345 additions and 20 deletions
+4
View File
@@ -3,10 +3,14 @@ mod parse;
pub use model::ApiSchema;
pub(crate) use model::Arguments;
pub(crate) use model::ArraySchema;
pub(crate) use model::ConstraintSet;
pub(crate) use model::JsonType;
pub(crate) use model::ObjectSchema;
pub(crate) use model::SchemaId;
pub(crate) use model::SchemaNode;
pub(crate) use model::SchemaRules;
pub(crate) use model::TypeSet;
pub(crate) use model::UnionKind;
pub(crate) use model::UnionSchema;
pub(crate) use model::ValueSet;
+12
View File
@@ -1,12 +1,20 @@
mod array;
mod constraints;
mod method;
mod object;
mod union;
mod value;
pub(crate) use array::ArraySchema;
pub(crate) use constraints::ConstraintSet;
pub(crate) use constraints::annotation;
#[cfg(test)]
pub(crate) use method::Argument;
pub(crate) use method::Arguments;
pub(crate) use method::Method;
pub(crate) use object::ObjectSchema;
pub(crate) use union::UnionKind;
pub(crate) use union::UnionSchema;
pub(crate) use value::JsonType;
pub(crate) use value::TypeSet;
pub(crate) use value::ValueSet;
@@ -41,6 +49,10 @@ pub struct SchemaRules {
pub types: Option<TypeSet>,
pub values: Option<ValueSet>,
pub object: Option<ObjectSchema>,
pub array: Option<ArraySchema>,
pub any_of: Option<UnionSchema>,
pub one_of: Option<UnionSchema>,
pub constraints: ConstraintSet,
}
impl ApiSchema {
@@ -0,0 +1,63 @@
use crate::JsonType;
use crate::SchemaId;
use crate::TypeSet;
use crate::parse::Parser;
use anyhow::Result;
use anyhow::bail;
use serde_json::Map;
use serde_json::Value;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ArraySchema {
pub items: Items,
pub additional_items: AdditionalItems,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Items {
Any,
Each(SchemaId),
Tuple(Vec<SchemaId>),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AdditionalItems {
Any,
Forbidden,
Schema(SchemaId),
}
impl ArraySchema {
pub(crate) fn parse(
parser: &mut Parser<'_>,
object: &Map<String, Value>,
types: Option<&TypeSet>,
) -> Result<Option<Self>> {
let items = match object.get("items") {
None if !types.is_some_and(|types| types.declared.contains(&JsonType::Array)) => {
return Ok(None);
}
None | Some(Value::Bool(true)) => Items::Any,
Some(value @ (Value::Bool(false) | Value::Object(_))) => {
Items::Each(parser.parse_schema(value)?)
}
Some(Value::Array(values)) => Items::Tuple(
values
.iter()
.map(|value| parser.parse_schema(value))
.collect::<Result<_>>()?,
),
Some(_) => bail!("JSON Schema items must be a schema or array of schemas"),
};
let additional_items = match object.get("additionalItems") {
None | Some(Value::Bool(true)) => AdditionalItems::Any,
Some(Value::Bool(false)) => AdditionalItems::Forbidden,
Some(value @ Value::Object(_)) => AdditionalItems::Schema(parser.parse_schema(value)?),
Some(_) => bail!("JSON Schema additionalItems must be a boolean or object"),
};
Ok(Some(Self {
items,
additional_items,
}))
}
}
@@ -0,0 +1,195 @@
use anyhow::Result;
use anyhow::anyhow;
use serde_json::Map;
use serde_json::Number;
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum LowerBound {
ExclusiveMinimum,
MinItems,
MinLength,
MinProperties,
Minimum,
}
impl LowerBound {
fn as_str(self) -> &'static str {
match self {
Self::ExclusiveMinimum => "exclusiveMinimum",
Self::MinItems => "minItems",
Self::MinLength => "minLength",
Self::MinProperties => "minProperties",
Self::Minimum => "minimum",
}
}
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum UpperBound {
ExclusiveMaximum,
MaxItems,
MaxLength,
MaxProperties,
Maximum,
}
impl UpperBound {
fn as_str(self) -> &'static str {
match self {
Self::ExclusiveMaximum => "exclusiveMaximum",
Self::MaxItems => "maxItems",
Self::MaxLength => "maxLength",
Self::MaxProperties => "maxProperties",
Self::Maximum => "maximum",
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ConstraintSet {
pub lower_bounds: BTreeMap<LowerBound, Number>,
pub upper_bounds: BTreeMap<UpperBound, Number>,
pub unique_items: Option<bool>,
pub opaque: BTreeMap<String, Value>,
}
impl ConstraintSet {
pub(crate) fn parse(object: &Map<String, Value>) -> Result<Self> {
let mut constraints = Self::default();
for (key, value) in object {
if annotation(key) || structural_keyword(key) {
continue;
}
match key.as_str() {
"minimum" => constraints.insert_lower(LowerBound::Minimum, value)?,
"exclusiveMinimum" => {
constraints.insert_lower(LowerBound::ExclusiveMinimum, value)?
}
"minLength" => constraints.insert_lower(LowerBound::MinLength, value)?,
"minItems" => constraints.insert_lower(LowerBound::MinItems, value)?,
"minProperties" => constraints.insert_lower(LowerBound::MinProperties, value)?,
"maximum" => constraints.insert_upper(UpperBound::Maximum, value)?,
"exclusiveMaximum" => {
constraints.insert_upper(UpperBound::ExclusiveMaximum, value)?
}
"maxLength" => constraints.insert_upper(UpperBound::MaxLength, value)?,
"maxItems" => constraints.insert_upper(UpperBound::MaxItems, value)?,
"maxProperties" => constraints.insert_upper(UpperBound::MaxProperties, value)?,
"uniqueItems" => {
constraints.unique_items = Some(
value
.as_bool()
.ok_or_else(|| anyhow!("JSON Schema uniqueItems must be a boolean"))?,
);
}
_ => {
if contains_reference(value) {
return Err(anyhow!(
"JSON Schema references inside {key} are not supported"
));
}
constraints.opaque.insert(key.clone(), normalized(value));
}
}
}
Ok(constraints)
}
fn insert_lower(&mut self, kind: LowerBound, value: &Value) -> Result<()> {
self.lower_bounds.insert(
kind,
value
.as_number()
.cloned()
.ok_or_else(|| anyhow!("JSON Schema {} must be numeric", kind.as_str()))?,
);
Ok(())
}
fn insert_upper(&mut self, kind: UpperBound, value: &Value) -> Result<()> {
self.upper_bounds.insert(
kind,
value
.as_number()
.cloned()
.ok_or_else(|| anyhow!("JSON Schema {} must be numeric", kind.as_str()))?,
);
Ok(())
}
}
pub(crate) fn normalized(value: &Value) -> Value {
fn visit(value: &Value, parent: Option<&str>) -> Value {
match value {
Value::Array(values) => {
let mut values = values
.iter()
.map(|value| visit(value, /*parent*/ None))
.collect::<Vec<_>>();
if parent.is_some_and(|parent| {
matches!(
parent,
"allOf" | "anyOf" | "enum" | "oneOf" | "required" | "type"
)
}) {
values.sort_by_key(|value| serde_json::to_string(value).unwrap_or_default());
}
Value::Array(values)
}
Value::Object(object) => Value::Object(
object
.iter()
.filter(|(key, _)| !annotation(key))
.map(|(key, value)| (key.clone(), visit(value, Some(key))))
.collect(),
),
_ => value.clone(),
}
}
visit(value, /*parent*/ None)
}
pub(crate) fn annotation(keyword: &str) -> bool {
matches!(
keyword,
"$comment"
| "$id"
| "$schema"
| "default"
| "deprecated"
| "description"
| "examples"
| "readOnly"
| "title"
| "writeOnly"
)
}
fn contains_reference(value: &Value) -> bool {
match value {
Value::Array(values) => values.iter().any(contains_reference),
Value::Object(object) => {
object.contains_key("$ref") || object.values().any(contains_reference)
}
_ => false,
}
}
fn structural_keyword(keyword: &str) -> bool {
matches!(
keyword,
"$ref"
| "additionalItems"
| "additionalProperties"
| "anyOf"
| "const"
| "enum"
| "items"
| "oneOf"
| "properties"
| "required"
| "type"
)
}
@@ -19,6 +19,7 @@ pub struct Method {
pub enum Arguments {
None,
Map(Argument),
List(Argument),
Value(Argument),
}
@@ -97,6 +98,8 @@ impl Arguments {
};
if rules.object.is_some() {
Ok(Self::Map(argument))
} else if rules.array.is_some() {
Ok(Self::List(argument))
} else {
Ok(Self::Value(argument))
}
@@ -0,0 +1,40 @@
use crate::SchemaId;
use crate::parse::Parser;
use anyhow::Result;
use anyhow::anyhow;
use serde_json::Map;
use serde_json::Value;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum UnionKind {
AnyOf,
OneOf,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UnionSchema {
pub kind: UnionKind,
pub variants: Vec<SchemaId>,
}
impl UnionSchema {
pub(crate) fn parse(
parser: &mut Parser<'_>,
object: &Map<String, Value>,
keyword: &str,
kind: UnionKind,
) -> Result<Option<Self>> {
object
.get(keyword)
.map(|value| {
let variants = value
.as_array()
.ok_or_else(|| anyhow!("JSON Schema {keyword} must be an array"))?
.iter()
.map(|value| parser.parse_schema(value))
.collect::<Result<_>>()?;
Ok(Self { kind, variants })
})
.transpose()
}
}
+13 -16
View File
@@ -1,10 +1,15 @@
use crate::ApiSchema;
use crate::ArraySchema;
use crate::ConstraintSet;
use crate::ObjectSchema;
use crate::SchemaId;
use crate::SchemaNode;
use crate::SchemaRules;
use crate::TypeSet;
use crate::UnionKind;
use crate::UnionSchema;
use crate::ValueSet;
use crate::model::annotation;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
@@ -50,10 +55,18 @@ impl<'a> Parser<'a> {
let types = TypeSet::parse(object.get("type"))?;
let values = ValueSet::parse(object)?;
let object_schema = ObjectSchema::parse(self, object, types.as_ref())?;
let array = ArraySchema::parse(self, object, types.as_ref())?;
let any_of = UnionSchema::parse(self, object, "anyOf", UnionKind::AnyOf)?;
let one_of = UnionSchema::parse(self, object, "oneOf", UnionKind::OneOf)?;
let constraints = ConstraintSet::parse(object)?;
Ok(SchemaNode::Rules(Box::new(SchemaRules {
types,
values,
object: object_schema,
array,
any_of,
one_of,
constraints,
})))
}
@@ -82,22 +95,6 @@ fn single_all_of(object: &Map<String, Value>) -> Result<Option<&Value>> {
)
}
fn annotation(keyword: &str) -> bool {
matches!(
keyword,
"$comment"
| "$id"
| "$schema"
| "default"
| "deprecated"
| "description"
| "examples"
| "readOnly"
| "title"
| "writeOnly"
)
}
#[cfg(test)]
use crate::Arguments;
#[cfg(test)]
+15 -4
View File
@@ -7,6 +7,7 @@ fn parses_methods_into_typed_argument_shapes() -> Result<()> {
let schema = json!({
"oneOf": [
request("map", json!({ "properties": { "value": { "type": "string" } }, "type": "object" }), /*required*/ true),
request("list", json!({ "items": { "type": "string" }, "type": "array" }), /*required*/ true),
request("value", json!({ "type": "null" }), /*required*/ false),
{
"properties": { "method": { "const": "none" } },
@@ -18,6 +19,10 @@ fn parses_methods_into_typed_argument_shapes() -> Result<()> {
let parsed = ApiSchema::parse(&schema)?;
assert!(matches!(parsed.methods["map"].arguments, Arguments::Map(_)));
assert!(matches!(
parsed.methods["list"].arguments,
Arguments::List(_)
));
assert!(matches!(
parsed.methods["value"].arguments,
Arguments::Value(Argument {
@@ -43,7 +48,7 @@ fn keeps_refs_typed_without_expanding_recursive_definitions() -> Result<()> {
schema["definitions"] = json!({
"Params": {
"properties": {
"child": { "$ref": "#/definitions/Params" },
"child": { "anyOf": [{ "$ref": "#/definitions/Params" }, { "type": "null" }] },
"name": { "allOf": [{ "$ref": "#/definitions/Name" }], "description": "alias" }
},
"type": "object"
@@ -84,10 +89,12 @@ fn rejects_malformed_protocol_shapes_and_direct_ref_cycles() {
"allOf": [{ "type": "string" }, { "minLength": 1 }]
}));
assert!(ApiSchema::parse(&unsupported_all_of).is_err());
}
fn request_schema(params: serde_json::Value) -> serde_json::Value {
json!({ "oneOf": [request("test/method", params, /*required*/ true)] })
let mut opaque_ref = request_schema(json!({
"not": { "$ref": "#/definitions/Value" }
}));
opaque_ref["definitions"] = json!({ "Value": { "type": "string" } });
assert!(ApiSchema::parse(&opaque_ref).is_err());
}
fn request(method: &str, params: serde_json::Value, required: bool) -> serde_json::Value {
@@ -105,3 +112,7 @@ fn request(method: &str, params: serde_json::Value, required: bool) -> serde_jso
"type": "object"
})
}
fn request_schema(params: serde_json::Value) -> serde_json::Value {
json!({ "oneOf": [request("test/method", params, /*required*/ true)] })
}