diff --git a/src/ambient.rs b/src/ambient.rs new file mode 100644 index 0000000..ea1f47b --- /dev/null +++ b/src/ambient.rs @@ -0,0 +1,300 @@ +//! Ambient UCP protocol-namespace materialization. +//! +//! Makes implicit `ucp` namespaces explicit before operation resolution. + +use serde_json::{Map, Value}; +use url::Url; + +use crate::error::ResolveError; +use crate::resolver; +use crate::types::ResolveOptions; + +const AMBIENT_UCP_MEMBERS_DEF_KEY: &str = "__ucp_ambient_members"; + +/// Resolve a schema with ambient UCP protocol-namespace materialization. +/// +/// `members` is the central `ucp` namespace member registry. It must already be +/// self-contained/bundled: the helper is detached from its original resource and +/// installed under the operation schema root `$defs`, so local `#/$defs/...` +/// references inside `members` would resolve against the operation root. +/// +/// The operation schema root must carry an absolute `$id`. Injected helper refs +/// are absolute URI references derived from that root resource, with a +/// collision-free helper `$defs` key. +/// +/// # Errors +/// +/// Returns `ResolveError` for non-object or non-absolute roots, non-object root +/// `$defs`, unbundled members, missing helpers, or invalid UCP annotations. +pub fn resolve_with_ucp_members( + schema: &Value, + members: &Value, + options: &ResolveOptions, +) -> Result { + let context = AmbientContext::new(schema)?; + reject_local_member_refs(members)?; + let registered_members = registered_member_names(members)?; + let mut materialized = schema.clone(); + materialize_schema(&mut materialized, Scope::Normal, &context); + let mut helper = members.clone(); + materialize_schema(&mut helper, Scope::DirectNamespace, &context); + install_members_helper(&mut materialized, &context.helper_key, helper)?; + let mut resolved = resolver::resolve(&materialized, options)?; + mark_omitted_registered_members_false(&mut resolved, ®istered_members, &context.helper_key)?; + Ok(resolved) +} + +struct AmbientContext { + helper_key: String, + helper_ref: String, +} + +impl AmbientContext { + fn new(schema: &Value) -> Result { + let root = schema.as_object().ok_or_else(|| { + invalid("ambient UCP materialization requires object root with absolute $id") + })?; + let id = root + .get("$id") + .and_then(Value::as_str) + .ok_or_else(|| invalid("ambient UCP materialization requires an absolute root $id"))?; + let mut root_uri = Url::parse(id).map_err(|source| { + invalid(format!("ambient UCP root $id must be absolute: {source}")) + })?; + let helper_key = allocate_helper_key(root)?; + root_uri.set_fragment(Some(&format!("/$defs/{helper_key}"))); + Ok(Self { + helper_ref: root_uri.to_string(), + helper_key, + }) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Scope { + Normal, + /// Direct value of a reserved `ucp` protocol namespace. Same-instance + /// applicators stay in this scope so `ucp.ucp` remains prohibited through + /// composition and conditionals. Child-instance schemas return to normal + /// materialization. + DirectNamespace, +} + +fn materialize_schema(schema: &mut Value, scope: Scope, context: &AmbientContext) { + match schema { + Value::Object(map) => materialize_object(map, scope, context), + Value::Bool(true) if scope == Scope::DirectNamespace => { + let mut map = Map::new(); + mark_direct_namespace(&mut map); + *schema = Value::Object(map); + } + Value::Array(items) => { + for item in items { + materialize_schema(item, Scope::Normal, context); + } + } + _ => {} + } +} + +fn materialize_object(map: &mut Map, scope: Scope, context: &AmbientContext) { + for (key, value) in map.iter_mut() { + match key.as_str() { + "properties" => materialize_properties(value, scope, context), + "$defs" | "definitions" | "patternProperties" => { + materialize_schema_map(value, Scope::Normal, context); + } + "dependentSchemas" => materialize_schema_map(value, scope, context), + "allOf" | "anyOf" | "oneOf" => materialize_schema_array(value, scope, context), + "if" | "then" | "else" | "not" => materialize_schema(value, scope, context), + "additionalProperties" + | "unevaluatedProperties" + | "propertyNames" + | "items" + | "contains" + | "unevaluatedItems" + | "contentSchema" => materialize_schema(value, Scope::Normal, context), + "prefixItems" => materialize_schema_array(value, Scope::Normal, context), + _ => {} + } + } + match scope { + Scope::Normal => inject_ambient_ref_if_eligible(map, context), + Scope::DirectNamespace => mark_direct_namespace(map), + } +} + +fn materialize_properties(value: &mut Value, scope: Scope, context: &AmbientContext) { + let Some(properties) = value.as_object_mut() else { + return; + }; + for (name, schema) in properties { + let child_scope = match (scope, name.as_str()) { + (Scope::DirectNamespace, "ucp") => { + *schema = Value::Bool(false); + continue; + } + (Scope::Normal, "ucp") => Scope::DirectNamespace, + _ => Scope::Normal, + }; + materialize_schema(schema, child_scope, context); + } +} + +fn materialize_schema_map(value: &mut Value, scope: Scope, context: &AmbientContext) { + if let Some(map) = value.as_object_mut() { + for schema in map.values_mut() { + materialize_schema(schema, scope, context); + } + } +} + +fn materialize_schema_array(value: &mut Value, scope: Scope, context: &AmbientContext) { + if let Some(items) = value.as_array_mut() { + for item in items { + materialize_schema(item, scope, context); + } + } +} + +fn inject_ambient_ref_if_eligible(map: &mut Map, context: &AmbientContext) { + let Some(properties) = map.get_mut("properties").and_then(Value::as_object_mut) else { + return; + }; + if !properties.is_empty() && !properties.contains_key("ucp") { + properties.insert("ucp".to_string(), absolute_ref_schema(&context.helper_ref)); + } +} + +fn mark_direct_namespace(map: &mut Map) { + let properties = map + .entry("properties".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if let Value::Object(properties) = properties { + properties.insert("ucp".to_string(), Value::Bool(false)); + } + map.insert("additionalProperties".to_string(), empty_schema()); + if has_composition(map) { + map.insert("unevaluatedProperties".to_string(), empty_schema()); + } +} + +fn allocate_helper_key(root: &Map) -> Result { + let Some(defs) = root.get("$defs") else { + return Ok(AMBIENT_UCP_MEMBERS_DEF_KEY.to_string()); + }; + let defs = defs.as_object().ok_or_else(|| { + invalid("ambient UCP materialization requires root $defs to be an object") + })?; + let mut key = AMBIENT_UCP_MEMBERS_DEF_KEY.to_string(); + for suffix in 1.. { + if !defs.contains_key(&key) { + return Ok(key); + } + key = format!("{AMBIENT_UCP_MEMBERS_DEF_KEY}_{suffix}"); + } + unreachable!("unbounded suffix search should always find a helper key") +} + +fn install_members_helper( + schema: &mut Value, + key: &str, + helper: Value, +) -> Result<(), ResolveError> { + let root = schema + .as_object_mut() + .ok_or_else(|| invalid("ambient UCP materialization requires an object schema root"))?; + let defs = root + .entry("$defs".to_string()) + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .ok_or_else(|| { + invalid("ambient UCP materialization requires root $defs to be an object") + })?; + defs.insert(key.to_string(), helper); + Ok(()) +} + +fn reject_local_member_refs(members: &Value) -> Result<(), ResolveError> { + if let Some(reference) = find_local_ref(members) { + return Err(invalid(format!( + "ambient UCP members registry must be bundled: found local $ref '{reference}'" + ))); + } + Ok(()) +} + +fn find_local_ref(value: &Value) -> Option<&str> { + match value { + Value::Object(map) => { + if let Some(reference) = map + .get("$ref") + .and_then(Value::as_str) + .filter(|reference| reference.starts_with('#')) + { + return Some(reference); + } + map.values().find_map(find_local_ref) + } + Value::Array(items) => items.iter().find_map(find_local_ref), + _ => None, + } +} + +fn registered_member_names(members: &Value) -> Result, ResolveError> { + let members = members + .as_object() + .ok_or_else(|| invalid("ambient UCP members registry must be an object schema"))?; + let properties = members + .get("properties") + .and_then(Value::as_object) + .ok_or_else(|| invalid("ambient UCP members registry must have object properties"))?; + + Ok(properties.keys().cloned().collect()) +} + +fn mark_omitted_registered_members_false( + schema: &mut Value, + registered_members: &[String], + helper_key: &str, +) -> Result<(), ResolveError> { + let helper = schema + .get_mut("$defs") + .and_then(Value::as_object_mut) + .and_then(|defs| defs.get_mut(helper_key)) + .and_then(Value::as_object_mut) + .ok_or_else(|| { + invalid(format!( + "ambient UCP helper '{helper_key}' missing after resolution" + )) + })?; + let properties = helper + .get_mut("properties") + .and_then(Value::as_object_mut) + .ok_or_else(|| { + invalid(format!( + "ambient UCP helper '{helper_key}' must have properties" + )) + })?; + for name in registered_members { + if !properties.contains_key(name) { + properties.insert(name.clone(), Value::Bool(false)); + } + } + Ok(()) +} + +fn has_composition(map: &Map) -> bool { + map.contains_key("allOf") || map.contains_key("anyOf") || map.contains_key("oneOf") +} +fn absolute_ref_schema(reference: &str) -> Value { + serde_json::json!({ "$ref": reference }) +} +fn empty_schema() -> Value { + Value::Object(Map::new()) +} +fn invalid(message: impl Into) -> ResolveError { + ResolveError::InvalidSchema { + message: message.into(), + } +} diff --git a/src/lib.rs b/src/lib.rs index b763700..94a1d81 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,6 +54,7 @@ //! { "ucp_request": { "create": "omit", "update": "required" } } //! ``` +mod ambient; mod compose; mod error; mod linter; @@ -63,6 +64,7 @@ mod resolver; mod types; mod validator; +pub use ambient::resolve_with_ucp_members; pub use compose::{ capability_short_name, check_version_constraints, compose_from_payload, compose_schema, detect_direction, extract_capabilities, extract_capabilities_from_profile, diff --git a/src/resolver.rs b/src/resolver.rs index bba991a..37604c7 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -98,12 +98,16 @@ fn close_additional_properties_inner(value: &mut Value, in_composition_branch: b } } } - "items" | "additionalProperties" | "unevaluatedProperties" => { + "items" + | "additionalProperties" + | "unevaluatedProperties" + | "contains" + | "unevaluatedItems" => { // Schema values - recurse close_additional_properties_inner(child, false); } - "$defs" | "definitions" => { - // Definitions - recurse into each + "$defs" | "definitions" | "patternProperties" => { + // Schema maps - recurse into each value schema if let Value::Object(defs) = child { for def_value in defs.values_mut() { close_additional_properties_inner(def_value, false); @@ -119,6 +123,15 @@ fn close_additional_properties_inner(value: &mut Value, in_composition_branch: b } } } + "prefixItems" => { + // Tuple item schemas are child-instance schemas, not + // same-instance composition branches. + if let Value::Array(arr) = child { + for item in arr { + close_additional_properties_inner(item, false); + } + } + } _ => {} } } diff --git a/src/validator.rs b/src/validator.rs index 6e96d43..9e7f3a2 100644 --- a/src/validator.rs +++ b/src/validator.rs @@ -107,6 +107,9 @@ fn select_def(schema: &Value, name: &str, mode: SelectMode) -> Result String { + format!("{TEST_ROOT_ID}#/$defs/{key}") +} + +fn default_helper_ref() -> Value { + json!(helper_ref(HELPER_KEY)) +} + +fn with_root_id(schema: &Value) -> Value { + let mut rooted = schema.clone(); + if let Value::Object(map) = &mut rooted { + map.entry("$id".to_string()).or_insert(json!(TEST_ROOT_ID)); + } + rooted +} + +fn members_schema() -> Value { + json!({ + "type": "object", + "description": "Minimal central UCP members fixture for ambient materialization tests.", + "properties": { + "map_order": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + }, + "ucp_request": "omit", + "ucp_response": "optional" + }, + "member_config": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" } + }, + "ucp_response": "optional" + } + }, + "additionalProperties": true + }) +} + +fn response_options() -> ResolveOptions { + ResolveOptions::new(Direction::Response, "search").strict(true) +} + +fn request_options() -> ResolveOptions { + ResolveOptions::new(Direction::Request, "search").strict(true) +} + +fn resolve_response(schema: &Value) -> Value { + let schema = with_root_id(schema); + resolve_with_ucp_members(&schema, &members_schema(), &response_options()).unwrap() +} + +fn resolve_request(schema: &Value) -> Value { + let schema = with_root_id(schema); + resolve_with_ucp_members(&schema, &members_schema(), &request_options()).unwrap() +} + +fn assert_valid(schema: &Value, payload: Value) { + validate_against_schema(schema, &payload).unwrap_or_else(|err| { + panic!("expected valid payload {payload:#}; got {err:?}"); + }); +} + +fn assert_invalid(schema: &Value, payload: Value) { + assert!( + matches!( + validate_against_schema(schema, &payload), + Err(ValidateError::Invalid { .. }) + ), + "expected invalid payload {payload:#}" + ); +} + +#[test] +fn materializes_optional_ucp_property_at_structured_scopes() { + let schema = json!({ + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "child": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + } + }); + + let resolved = resolve_response(&schema); + + assert_eq!(resolved["properties"]["ucp"]["$ref"], default_helper_ref()); + assert_eq!( + resolved["properties"]["child"]["properties"]["ucp"]["$ref"], + default_helper_ref() + ); + assert!(!resolved["required"] + .as_array() + .unwrap() + .contains(&json!("ucp"))); +} + +#[test] +fn strict_accepts_valid_ambient_member_and_rejects_unknown_domain_field() { + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + } + }); + let resolved = resolve_response(&schema); + + assert_valid( + &resolved, + json!({ + "name": "Widget", + "ucp": { "map_order": { "name": ["en", "fr"] } } + }), + ); + assert_invalid( + &resolved, + json!({ + "name": "Widget", + "unknown_domain_field": true, + "ucp": { "future_member": { "anything": true } } + }), + ); +} + +#[test] +fn malformed_known_ambient_member_is_rejected_by_stock_validator() { + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + } + }); + let resolved = resolve_response(&schema); + + assert_invalid( + &resolved, + json!({ + "name": "Widget", + "ucp": { "map_order": { "name": "not-an-array" } } + }), + ); +} + +#[test] +fn dictionary_key_named_ucp_remains_ordinary_data() { + let schema = json!({ + "type": "object", + "properties": { + "attribution": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + }); + let resolved = resolve_response(&schema); + + assert!(resolved["properties"]["attribution"]["properties"].is_null()); + assert_valid( + &resolved, + json!({ + "attribution": { "ucp": "ordinary dictionary value" } + }), + ); +} + +#[test] +fn structured_dictionary_values_are_eligible_for_ambient_ucp() { + let schema = json!({ + "type": "object", + "properties": { + "registry": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + } + } + }); + let resolved = resolve_response(&schema); + + assert_eq!( + resolved["properties"]["registry"]["additionalProperties"]["properties"]["ucp"]["$ref"], + default_helper_ref() + ); + assert_valid( + &resolved, + json!({ + "registry": { + "ucp": { + "id": "dictionary-key-named-ucp", + "ucp": { "map_order": { "id": ["first"] } } + } + } + }), + ); +} + +#[test] +fn pattern_property_values_are_eligible_for_ambient_ucp() { + let schema = json!({ + "type": "object", + "properties": { + "registry": { + "type": "object", + "patternProperties": { + "^item:": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + } + } + } + }); + let resolved = resolve_response(&schema); + + assert_eq!( + resolved["properties"]["registry"]["patternProperties"]["^item:"]["properties"]["ucp"] + ["$ref"], + default_helper_ref() + ); + assert_valid( + &resolved, + json!({ + "registry": { + "item:1": { + "id": "p1", + "ucp": { "map_order": { "id": ["first"] } } + } + } + }), + ); +} + +#[test] +fn strict_pattern_properties_value_rejects_unknown_domain_properties() { + let schema = json!({ + "type": "object", + "properties": { + "registry": { + "type": "object", + "patternProperties": { + "^item:": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + } + } + } + }); + let resolved = resolve_response(&schema); + + assert_invalid( + &resolved, + json!({ + "registry": { + "item:1": { + "id": "p1", + "unknown_domain_field": true, + "ucp": { "map_order": { "id": ["first"] } } + } + } + }), + ); +} + +#[test] +fn child_instance_applicators_are_traversed_with_normal_scope() { + let structured = json!({ + "type": "object", + "properties": { + "id": { "type": "string" } + } + }); + let cases = vec![ + ( + "additionalProperties", + json!({ "type": "object", "additionalProperties": structured.clone() }), + "/properties/value/additionalProperties/properties/ucp/$ref", + ), + ( + "unevaluatedProperties", + json!({ "type": "object", "unevaluatedProperties": structured.clone() }), + "/properties/value/unevaluatedProperties/properties/ucp/$ref", + ), + ( + "propertyNames", + json!({ "type": "object", "propertyNames": structured.clone() }), + "/properties/value/propertyNames/properties/ucp/$ref", + ), + ( + "patternProperties", + json!({ "type": "object", "patternProperties": { "^item:": structured.clone() } }), + "/properties/value/patternProperties/^item:/properties/ucp/$ref", + ), + ( + "items", + json!({ "type": "array", "items": structured.clone() }), + "/properties/value/items/properties/ucp/$ref", + ), + ( + "prefixItems", + json!({ "type": "array", "prefixItems": [structured.clone()] }), + "/properties/value/prefixItems/0/properties/ucp/$ref", + ), + ( + "contains", + json!({ "type": "array", "contains": structured.clone() }), + "/properties/value/contains/properties/ucp/$ref", + ), + ( + "unevaluatedItems", + json!({ "type": "array", "prefixItems": [{ "type": "string" }], "unevaluatedItems": structured.clone() }), + "/properties/value/unevaluatedItems/properties/ucp/$ref", + ), + ( + "contentSchema", + json!({ "type": "string", "contentMediaType": "application/json", "contentSchema": structured }), + "/properties/value/contentSchema/properties/ucp/$ref", + ), + ]; + + for (keyword, value_schema, pointer) in cases { + let schema = json!({ + "type": "object", + "properties": { + "value": value_schema + } + }); + let resolved = resolve_response(&schema); + assert_eq!( + resolved.pointer(pointer), + Some(&default_helper_ref()), + "{keyword} should traverse child-instance schema at {pointer}" + ); + } +} + +#[test] +fn unevaluated_items_value_can_use_ambient_ucp_members() { + let schema = json!({ + "type": "array", + "prefixItems": [{ "type": "string" }], + "unevaluatedItems": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + }); + let resolved = resolve_response(&schema); + + assert_eq!( + resolved["unevaluatedItems"]["properties"]["ucp"]["$ref"], + default_helper_ref() + ); + assert_valid( + &resolved, + json!([ + "first", + { + "id": "p1", + "ucp": { "map_order": { "id": ["first"] } } + } + ]), + ); +} + +#[test] +fn child_instances_beneath_direct_namespace_return_to_normal_scope() { + let schema = json!({ + "type": "object", + "properties": { + "ucp": { + "type": "object", + "properties": { + "child": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + }, + "patternProperties": { + "^member:": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + } + } + } + }); + let resolved = resolve_response(&schema); + + assert_eq!( + resolved["properties"]["ucp"]["properties"]["child"]["properties"]["ucp"]["$ref"], + default_helper_ref() + ); + assert_eq!( + resolved["properties"]["ucp"]["patternProperties"]["^member:"]["properties"]["ucp"]["$ref"], + default_helper_ref() + ); + assert_eq!( + resolved["properties"]["ucp"]["properties"]["ucp"], + json!(false) + ); +} + +#[test] +fn direct_namespace_same_instance_applicators_preserve_direct_scope() { + let cases = vec![ + ( + "allOf", + json!({ "allOf": [{ "type": "object", "properties": { "ucp": { "type": "object" } } }] }), + "/properties/ucp/allOf/0/properties/ucp", + ), + ( + "anyOf", + json!({ "anyOf": [{ "type": "object", "properties": { "ucp": { "type": "object" } } }] }), + "/properties/ucp/anyOf/0/properties/ucp", + ), + ( + "oneOf", + json!({ "oneOf": [{ "type": "object", "properties": { "ucp": { "type": "object" } } }] }), + "/properties/ucp/oneOf/0/properties/ucp", + ), + ( + "if", + json!({ "if": { "type": "object", "properties": { "ucp": { "type": "object" } } } }), + "/properties/ucp/if/properties/ucp", + ), + ( + "then", + json!({ "then": { "type": "object", "properties": { "ucp": { "type": "object" } } } }), + "/properties/ucp/then/properties/ucp", + ), + ( + "else", + json!({ "else": { "type": "object", "properties": { "ucp": { "type": "object" } } } }), + "/properties/ucp/else/properties/ucp", + ), + ( + "not", + json!({ "not": { "type": "object", "properties": { "ucp": { "type": "object" } } } }), + "/properties/ucp/not/properties/ucp", + ), + ( + "dependentSchemas", + json!({ "dependentSchemas": { "flag": { "type": "object", "properties": { "ucp": { "type": "object" } } } } }), + "/properties/ucp/dependentSchemas/flag/properties/ucp", + ), + ]; + + for (keyword, ucp_schema, pointer) in cases { + let schema = json!({ + "type": "object", + "properties": { + "ucp": ucp_schema + } + }); + let resolved = resolve_response(&schema); + assert_eq!( + resolved.pointer(pointer), + Some(&json!(false)), + "{keyword} should preserve direct namespace scope at {pointer}" + ); + } +} + +#[test] +fn direct_namespace_conditional_rejects_ucp_recursion() { + let schema = json!({ + "type": "object", + "properties": { + "ucp": { + "type": "object", + "properties": { + "kind": { "const": "guarded" } + }, + "if": { + "properties": { + "kind": { "const": "guarded" } + } + }, + "then": { + "properties": { + "hint": { "type": "string" }, + "ucp": { "type": "object" } + } + } + } + } + }); + let resolved = resolve_response(&schema); + + assert_eq!( + resolved["properties"]["ucp"]["then"]["properties"]["ucp"], + json!(false) + ); + assert_invalid( + &resolved, + json!({ "ucp": { "kind": "guarded", "ucp": {} } }), + ); + assert_valid( + &resolved, + json!({ "ucp": { "kind": "guarded", "hint": "kept", "future_member": true } }), + ); +} + +#[test] +fn direct_ucp_ucp_is_rejected_but_deeper_structured_children_can_have_ucp() { + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + } + }); + let resolved = resolve_response(&schema); + + assert_invalid(&resolved, json!({ "name": "Widget", "ucp": { "ucp": {} } })); + assert_valid( + &resolved, + json!({ + "name": "Widget", + "ucp": { + "member_config": { + "enabled": true, + "ucp": { "map_order": { "enabled": ["first"] } } + } + } + }), + ); +} + +#[test] +fn explicit_ucp_property_is_namespace_not_overwritten_and_rejects_direct_recursion() { + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "ucp": { + "type": "object", + "properties": { + "hint": { "type": "string" } + }, + "additionalProperties": true + } + } + }); + let resolved = resolve_response(&schema); + + assert!(resolved["properties"]["ucp"].get("$ref").is_none()); + assert_eq!( + resolved["properties"]["ucp"]["properties"]["hint"]["type"], + "string" + ); + assert_eq!( + resolved["properties"]["ucp"]["properties"]["ucp"], + json!(false) + ); + + assert_valid( + &resolved, + json!({ + "name": "Widget", + "ucp": { "hint": "kept", "future_member": { "anything": true } } + }), + ); + assert_invalid(&resolved, json!({ "name": "Widget", "ucp": { "ucp": {} } })); +} + +#[test] +fn explicit_ucp_composition_branches_are_direct_namespace_schemas() { + let schema = json!({ + "type": "object", + "properties": { + "ucp": { + "additionalProperties": false, + "allOf": [ + { + "type": "object", + "properties": { + "hint": { "type": "string" } + } + } + ] + } + } + }); + let resolved = resolve_response(&schema); + let explicit_ucp = &resolved["properties"]["ucp"]; + + assert_eq!(explicit_ucp["properties"]["ucp"], json!(false)); + assert_eq!(explicit_ucp["additionalProperties"], json!({})); + assert_eq!(explicit_ucp["unevaluatedProperties"], json!({})); + assert_eq!(explicit_ucp["allOf"][0]["properties"]["ucp"], json!(false)); + assert_valid( + &resolved, + json!({ "ucp": { "hint": "kept", "future_member": true } }), + ); + assert_invalid(&resolved, json!({ "ucp": { "ucp": {} } })); +} + +#[test] +fn request_omitted_known_member_is_false_while_unknown_future_member_stays_open() { + let schema = json!({ + "type": "object", + "properties": { + "query": { "type": "string" } + } + }); + let resolved = resolve_request(&schema); + + let helper = resolved["$defs"][HELPER_KEY].clone(); + assert!(helper.get("$id").is_none()); + assert_eq!(helper["properties"]["map_order"], json!(false)); + assert_eq!(helper["additionalProperties"], json!({})); + + assert_invalid( + &resolved, + json!({ "query": "boots", "ucp": { "map_order": { "x": ["a"] } } }), + ); + assert_valid( + &resolved, + json!({ "query": "boots", "ucp": { "future_member": { "anything": true } } }), + ); +} + +#[test] +fn members_registry_rejects_unbundled_local_refs() { + let schema = with_root_id(&json!({ + "type": "object", + "properties": { + "query": { "type": "string" } + } + })); + let members = json!({ + "type": "object", + "$defs": { + "map_order": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "properties": { + "map_order": { "$ref": "#/$defs/map_order" } + }, + "additionalProperties": true + }); + + let result = resolve_with_ucp_members(&schema, &members, &response_options()); + let Err(ResolveError::InvalidSchema { message }) = result else { + panic!("expected unbundled members registry to be rejected"); + }; + assert!(message.contains("#/$defs/map_order")); +} + +#[test] +fn members_registry_requires_direct_object_properties() { + let schema = with_root_id(&json!({ + "type": "object", + "properties": { + "query": { "type": "string" } + } + })); + + for members in [ + json!(false), + json!({ "allOf": [{ "properties": { "branch_member": { "type": "object" } } }] }), + json!({ "properties": false }), + ] { + let result = resolve_with_ucp_members(&schema, &members, &request_options()); + assert!(matches!(result, Err(ResolveError::InvalidSchema { .. }))); + } +} + +#[test] +fn include_future_surfaces_omitted_central_member_instead_of_false() { + let schema = json!({ + "type": "object", + "properties": { + "query": { "type": "string" } + } + }); + let members = json!({ + "type": "object", + "properties": { + "planned_member": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" } + }, + "ucp_request": { + "transition": { + "from": "omit", + "to": "optional", + "description": "Planned request member." + } + } + } + }, + "additionalProperties": true + }); + let options = ResolveOptions::new(Direction::Request, "search") + .strict(true) + .include_future(true); + let rooted_schema = with_root_id(&schema); + let resolved = resolve_with_ucp_members(&rooted_schema, &members, &options).unwrap(); + + let planned = &resolved["$defs"][HELPER_KEY]["properties"]["planned_member"]; + assert_ne!(planned, &json!(false)); + assert_eq!(planned["x-ucp-schema-transition"]["from"], json!("omit")); + assert_valid( + &resolved, + json!({ + "query": "boots", + "ucp": { "planned_member": { "enabled": true } } + }), + ); +} + +#[test] +fn explicit_ucp_omitted_by_annotation_is_not_reintroduced() { + let schema = json!({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "ucp": { + "type": "object", + "ucp_request": "omit", + "properties": { + "map_order": { "type": "object" } + }, + "additionalProperties": true + } + } + }); + + let resolved = resolve_request(&schema); + + assert!(resolved["properties"].get("ucp").is_none()); + assert_invalid( + &resolved, + json!({ "name": "Widget", "ucp": { "future": true } }), + ); +} + +#[test] +fn allof_and_selected_defs_preserve_absolute_helper_refs() { + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": TEST_ROOT_ID, + "$defs": { + "search_response": { + "allOf": [ + { + "type": "object", + "properties": { + "id": { "type": "string" } + } + }, + { + "type": "object", + "properties": { + "name": { "type": "string" } + } + } + ] + } + } + }); + let options = ResolveOptions::new(Direction::Response, "search") + .strict(true) + .def_name(Some("search_response".to_string())); + let resolved = resolve_with_ucp_members(&schema, &members_schema(), &options).unwrap(); + let selected = select_operation_schema(&resolved, &options).unwrap(); + + assert_eq!( + selected["$defs"]["search_response"]["allOf"][0]["properties"]["ucp"]["$ref"], + default_helper_ref() + ); + assert_eq!(selected["$id"], TEST_ROOT_ID); + assert!(selected["$defs"][HELPER_KEY].is_object()); + assert_valid( + &selected, + json!({ + "id": "p1", + "name": "Widget", + "ucp": { "map_order": { "name": ["first"] } } + }), + ); +} + +#[test] +fn nested_id_scope_does_not_break_absolute_helper_resolution() { + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": TEST_ROOT_ID, + "type": "object", + "properties": { + "nested": { + "$id": "https://example.invalid/nested-schema", + "type": "object", + "properties": { + "id": { "type": "string" } + } + } + } + }); + let resolved = resolve_response(&schema); + + assert_eq!( + resolved["properties"]["nested"]["properties"]["ucp"]["$ref"], + default_helper_ref() + ); + assert_valid( + &resolved, + json!({ + "nested": { + "id": "n1", + "ucp": { "map_order": { "id": ["first"] } } + } + }), + ); +} + +#[test] +fn helper_key_collision_uses_suffix_and_preserves_authored_defs() { + let collision_root_id = "https://example.invalid/schemas/collision"; + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": collision_root_id, + "$defs": { + "__ucp_ambient_members": { "type": "string" } + }, + "type": "object", + "properties": { + "name": { "type": "string" } + } + }); + let resolved = + resolve_with_ucp_members(&schema, &members_schema(), &response_options()).unwrap(); + + assert_eq!(resolved["$defs"][HELPER_KEY]["type"], json!("string")); + assert!(resolved["$defs"]["__ucp_ambient_members_1"].is_object()); + assert!(resolved["$defs"]["__ucp_ambient_members_1"] + .get("$id") + .is_none()); + assert_eq!( + resolved["properties"]["ucp"]["$ref"], + json!(format!( + "{collision_root_id}#/$defs/__ucp_ambient_members_1" + )) + ); + assert_valid( + &resolved, + json!({ + "name": "Widget", + "ucp": { "map_order": { "name": ["first"] } } + }), + ); +} + +#[test] +fn missing_or_non_absolute_root_id_is_invalid_schema() { + let missing_id = json!({ + "type": "object", + "properties": { + "name": { "type": "string" } + } + }); + let relative_id = json!({ + "$id": "relative/schema.json", + "type": "object", + "properties": { + "name": { "type": "string" } + } + }); + + for schema in [missing_id, relative_id] { + let result = resolve_with_ucp_members(&schema, &members_schema(), &response_options()); + assert!(matches!(result, Err(ResolveError::InvalidSchema { .. }))); + } +} + +#[test] +fn non_object_root_defs_is_invalid_schema() { + let schema = json!({ + "$id": TEST_ROOT_ID, + "$defs": false, + "type": "object", + "properties": { + "name": { "type": "string" } + } + }); + + let result = resolve_with_ucp_members(&schema, &members_schema(), &response_options()); + assert!(matches!(result, Err(ResolveError::InvalidSchema { .. }))); +} diff --git a/tests/resolve_test.rs b/tests/resolve_test.rs index dedf16e..12988ed 100644 --- a/tests/resolve_test.rs +++ b/tests/resolve_test.rs @@ -1601,6 +1601,59 @@ mod strict_mode { assert_eq!(result["items"]["additionalProperties"], json!(false)); } + #[test] + fn applies_to_other_child_instance_applicators() { + let child = json!({ + "type": "object", + "properties": { + "id": { "type": "string" } + } + }); + let schema = json!({ + "type": "object", + "properties": { + "registry": { + "type": "object", + "patternProperties": { + "^item:": child.clone() + } + }, + "tuple": { + "type": "array", + "prefixItems": [child.clone()] + }, + "set": { + "type": "array", + "contains": child.clone() + }, + "tail": { + "type": "array", + "prefixItems": [{ "type": "string" }], + "unevaluatedItems": child + } + } + }); + let options = ResolveOptions::new(Direction::Request, "create").strict(true); + let result = resolve(&schema, &options).unwrap(); + + assert_eq!( + result["properties"]["registry"]["patternProperties"]["^item:"]["additionalProperties"], + json!(false) + ); + assert_eq!( + result["properties"]["tuple"]["prefixItems"][0]["additionalProperties"], + json!(false) + ); + assert_eq!( + result["properties"]["set"]["contains"]["additionalProperties"], + json!(false) + ); + assert_eq!( + result["properties"]["tail"]["unevaluatedItems"]["additionalProperties"], + json!(false) + ); + } + #[test] fn applies_to_defs() { // Definitions should also be closed in strict mode