mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
parse composite schema constraints
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)] })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user