diff --git a/typify-impl/src/merge.rs b/typify-impl/src/merge.rs index b7649019..b8b37710 100644 --- a/typify-impl/src/merge.rs +++ b/typify-impl/src/merge.rs @@ -382,14 +382,20 @@ fn try_merge_with_each_subschema( .iter() .enumerate() .filter(|(jj, _)| *jj != ii) - .map(|(_, not_schema)| { - Schema::Object(SchemaObject { + .filter_map(|(_, not_schema)| { + // A disjoint branch is already excluded by the merged + // schema. Subtracting it again can lose information + // when a shared property accepts any value. + if try_merge_schema(&merged_schema, not_schema, defs).is_err() { + return None; + } + Some(Schema::Object(SchemaObject { subschemas: Some(Box::new(SubschemaValidation { not: Some(Box::new(not_schema.clone())), ..Default::default() })), ..Default::default() - }) + })) }); let joined_schema = [schema.clone(), other.clone()] .into_iter() @@ -1335,6 +1341,61 @@ mod tests { use super::try_merge_schema; + #[test] + fn disjoint_oneof_branches_keep_shared_and_variant_properties() { + let input: schemars::schema::Schema = serde_json::from_value(json!({ + "type": "object", + "properties": {"serverName": {"type": "string"}}, + "required": ["serverName"], + "oneOf": [ + { + "type": "object", + "properties": { + "mode": {"enum": ["form"]}, + "requestedSchema": {"type": "object"}, + "_meta": true + }, + "required": ["mode", "requestedSchema"] + }, + { + "type": "object", + "properties": { + "mode": {"enum": ["openai/form"]}, + "requestedSchema": true, + "_meta": true + }, + "required": ["mode", "requestedSchema"] + } + ] + })) + .unwrap(); + + let object = input.into_object(); + let merged = super::try_merge_with_subschemas( + schemars::schema::SchemaObject { + subschemas: None, + ..object.clone() + }, + object.subschemas.as_deref(), + &BTreeMap::new(), + ) + .unwrap(); + let branches = merged.subschemas.unwrap().one_of.unwrap(); + assert_eq!(branches.len(), 2); + for branch in branches { + let branch = branch.into_object(); + let parts = branch.subschemas.unwrap().all_of.unwrap(); + let properties = super::merge_all(&parts, &BTreeMap::new()) + .into_object() + .object + .unwrap() + .properties; + for key in ["serverName", "mode", "requestedSchema", "_meta"] { + assert!(properties.contains_key(key), "missing {key}"); + } + } + } + #[test] fn test_simple_merge() { let a = json!({ diff --git a/typify/tests/schemas/disjoint-oneof-shared-properties.json b/typify/tests/schemas/disjoint-oneof-shared-properties.json new file mode 100644 index 00000000..b9093d6d --- /dev/null +++ b/typify/tests/schemas/disjoint-oneof-shared-properties.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Elicitation": { + "title": "Elicitation", + "type": "object", + "properties": { + "serverName": { "type": "string" }, + "threadId": { "type": "string" } + }, + "required": ["serverName", "threadId"], + "oneOf": [ + { + "type": "object", + "properties": { + "mode": { "type": "string", "enum": ["form"] }, + "requestedSchema": { "type": "object" }, + "_meta": true + }, + "required": ["mode", "requestedSchema"] + }, + { + "type": "object", + "properties": { + "mode": { "type": "string", "enum": ["openai/form"] }, + "requestedSchema": true, + "_meta": true + }, + "required": ["mode", "requestedSchema"] + } + ] + } + } +} diff --git a/typify/tests/schemas/disjoint-oneof-shared-properties.rs b/typify/tests/schemas/disjoint-oneof-shared-properties.rs new file mode 100644 index 00000000..3f925c21 --- /dev/null +++ b/typify/tests/schemas/disjoint-oneof-shared-properties.rs @@ -0,0 +1,155 @@ +#![deny(warnings)] +#[doc = "`Elicitation`"] +#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)] +#[serde(untagged)] +pub enum Elicitation { + Variant0 { + #[serde( + rename = "_meta", + skip_serializing_if = "::std::option::Option::is_none" + )] + meta: ::std::option::Option<::serde_json::Value>, + mode: ElicitationVariant0Mode, + #[serde(rename = "requestedSchema")] + requested_schema: ::serde_json::Map<::std::string::String, ::serde_json::Value>, + #[serde(rename = "serverName")] + server_name: ::std::string::String, + #[serde(rename = "threadId")] + thread_id: ::std::string::String, + }, + Variant1 { + #[serde( + rename = "_meta", + skip_serializing_if = "::std::option::Option::is_none" + )] + meta: ::std::option::Option<::serde_json::Value>, + mode: ElicitationVariant1Mode, + #[serde(rename = "requestedSchema")] + requested_schema: ::serde_json::Value, + #[serde(rename = "serverName")] + server_name: ::std::string::String, + #[serde(rename = "threadId")] + thread_id: ::std::string::String, + }, +} +#[doc = "`ElicitationVariant0Mode`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ElicitationVariant0Mode { + #[serde(rename = "form")] + Form, +} +impl ::std::fmt::Display for ElicitationVariant0Mode { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Form => f.write_str("form"), + } + } +} +impl ::std::str::FromStr for ElicitationVariant0Mode { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "form" => Ok(Self::Form), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ElicitationVariant0Mode { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ElicitationVariant0Mode { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = "`ElicitationVariant1Mode`"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +pub enum ElicitationVariant1Mode { + #[serde(rename = "openai/form")] + OpenaiForm, +} +impl ::std::fmt::Display for ElicitationVariant1Mode { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::OpenaiForm => f.write_str("openai/form"), + } + } +} +impl ::std::str::FromStr for ElicitationVariant1Mode { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + match value { + "openai/form" => Ok(Self::OpenaiForm), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ElicitationVariant1Mode { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ElicitationVariant1Mode { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +#[doc = " Error types."] +pub mod error { + #[doc = r" Error from a `TryFrom` or `FromStr` implementation."] + pub struct ConversionError(::std::borrow::Cow<'static, str>); + impl ::std::error::Error for ConversionError {} + impl ::std::fmt::Display for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Display::fmt(&self.0, f) + } + } + impl ::std::fmt::Debug for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Debug::fmt(&self.0, f) + } + } + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } +} +fn main() {}