diff --git a/rust/shopping-client/openapi/shopping.oas3.json b/rust/shopping-client/openapi/shopping.oas3.json index 7e7b16cc..1521df74 100644 --- a/rust/shopping-client/openapi/shopping.oas3.json +++ b/rust/shopping-client/openapi/shopping.oas3.json @@ -973,7 +973,7 @@ "$ref": "#/components/schemas/product-metadata_base_schema" }, { - "additionalProperties": true, + "additionalProperties": {}, "properties": { "mailbox_size_mb": { "minimum": 0, @@ -2090,7 +2090,7 @@ "product-metadata_base_schema": { "$id": "https://godaddy.com/schemas/ucp/2026-04-08/shopping/catalog/product-metadata/base.json", "$schema": "https://json-schema.org/draft/2020-12/schema", - "additionalProperties": true, + "additionalProperties": {}, "properties": { "category": { "type": "string" @@ -2117,7 +2117,7 @@ "$ref": "#/components/schemas/product-metadata_base_schema" }, { - "additionalProperties": true, + "additionalProperties": {}, "properties": { "control_panel": { "type": "string" @@ -2692,7 +2692,7 @@ "$ref": "#/components/schemas/product-metadata_base_schema" }, { - "additionalProperties": true, + "additionalProperties": {}, "properties": { "assurance": { "type": "string" @@ -3343,7 +3343,7 @@ "variant-metadata_base_schema": { "$id": "https://godaddy.com/schemas/ucp/2026-04-08/shopping/catalog/variant-metadata/base.json", "$schema": "https://json-schema.org/draft/2020-12/schema", - "additionalProperties": true, + "additionalProperties": {}, "properties": { "default": { "type": "boolean" @@ -3360,7 +3360,7 @@ "$ref": "#/components/schemas/variant-metadata_base_schema" }, { - "additionalProperties": true, + "additionalProperties": {}, "properties": { "control_panel": { "type": "string" diff --git a/rust/shopping-client/src/lib.rs b/rust/shopping-client/src/lib.rs index f01f9226..72a956b6 100644 --- a/rust/shopping-client/src/lib.rs +++ b/rust/shopping-client/src/lib.rs @@ -68,6 +68,44 @@ mod tests { .expect("build client") } + // Product and variant `metadata` are open objects in the contract: the + // API returns merchant-defined keys (`subscription_type`, `term_type`, ...) + // that the schema does not name. The shopping commands print the typed + // response as JSON, so those keys must survive a decode/encode round trip. + #[test] + fn catalog_metadata_keeps_keys_the_contract_does_not_name() { + let payload = json!({ + "products": [{ + "id": "T1", + "metadata": { + "product_family": "email", + "mailbox_size_mb": 10240, + "subscription_type": "PAID" + }, + "variants": [{ + "id": "T1:1mo", + "metadata": { + "default": true, + "number_of_terms": 1, + "term_type": "MONTH" + } + }] + }] + }); + + let response: types::CatalogSearchSearchResponse = + serde_json::from_value(payload).expect("decode response"); + let output = serde_json::to_value(&response).expect("encode response"); + + let product = &output["products"][0]["metadata"]; + assert_eq!(product["subscription_type"], "PAID"); + assert_eq!(product["mailbox_size_mb"], 10240, "named fields still map"); + let variant = &output["products"][0]["variants"][0]["metadata"]; + assert_eq!(variant["number_of_terms"], 1); + assert_eq!(variant["term_type"], "MONTH"); + assert_eq!(variant["default"], true, "named fields still map"); + } + #[derive(Debug, Default)] struct RecordingObserver { requests: std::sync::Mutex>, diff --git a/rust/tools/generate-api-catalog/src/shopping_spec.rs b/rust/tools/generate-api-catalog/src/shopping_spec.rs index 6e3e265e..486624d8 100644 --- a/rust/tools/generate-api-catalog/src/shopping_spec.rs +++ b/rust/tools/generate-api-catalog/src/shopping_spec.rs @@ -71,6 +71,7 @@ pub(crate) fn refresh( remove_protocol_header_parameters(&mut spec); crate::spec_normalize::retain_2xx_responses(&mut spec); relax_response_schemas(&mut spec); + capture_open_response_properties(&mut spec)?; preserve_dynamic_ucp_response_metadata(&mut spec)?; preserve_payment_instrument_fields(&mut spec)?; add_idempotency_headers(&mut spec)?; @@ -155,6 +156,61 @@ fn remove_protocol_header_parameters(spec: &mut Value) { } } +/// Response schemas declared as open objects (`additionalProperties: true`) +/// whose undeclared keys the typed client must keep, so `--output json` shows +/// what the API returned (e.g. catalog `subscription_type`, `term_type`). +/// +/// Request-only schemas (`buyer`, `context`, `signals`, the checkout request +/// bodies, ...) are deliberately absent: a catch-all field would also be +/// required when the CLI builds them by hand. +const OPEN_RESPONSE_SCHEMAS: &[&str] = &[ + "product-metadata_base_schema", + "product-metadata_hosting_schema", + "email_schema", + "ssl_schema", + "variant-metadata_base_schema", + "variant-metadata_hosting_schema", +]; + +/// Progenitor's type generator ignores `additionalProperties: true` but turns +/// the equally permissive `{}` into a flattened `extra` map holding every key +/// the schema does not name. Rewrites each open object in +/// [`OPEN_RESPONSE_SCHEMAS`] (including its `allOf` branches) accordingly. +fn capture_open_response_properties(spec: &mut Value) -> Result<()> { + let schemas = spec + .pointer_mut("/components/schemas") + .and_then(Value::as_object_mut) + .context("Shopping component schemas are missing")?; + for name in OPEN_RESPONSE_SCHEMAS { + let schema = schemas + .get_mut(*name) + .with_context(|| format!("Shopping {name} schema is missing"))?; + open_additional_properties(schema); + } + Ok(()) +} + +fn open_additional_properties(value: &mut Value) { + match value { + Value::Object(map) => { + if map.contains_key("properties") + && map.get("additionalProperties") == Some(&Value::Bool(true)) + { + map.insert("additionalProperties".to_owned(), serde_json::json!({})); + } + for child in map.values_mut() { + open_additional_properties(child); + } + } + Value::Array(values) => { + for value in values { + open_additional_properties(value); + } + } + _ => {} + } +} + fn preserve_dynamic_ucp_response_metadata(spec: &mut Value) -> Result<()> { let schemas = spec .get_mut("components") @@ -383,8 +439,8 @@ mod tests { use serde_json::json; use super::{ - ensure_refs_resolved, preserve_payment_instrument_fields, prune_documentation_fields, - relax, repoint_schema_refs, + OPEN_RESPONSE_SCHEMAS, capture_open_response_properties, ensure_refs_resolved, + preserve_payment_instrument_fields, prune_documentation_fields, relax, repoint_schema_refs, }; #[test] @@ -548,6 +604,58 @@ mod tests { ); } + fn spec_with_open_response_schemas() -> serde_json::Value { + let mut schemas = serde_json::Map::new(); + for name in OPEN_RESPONSE_SCHEMAS { + schemas.insert( + (*name).to_owned(), + json!({ + "allOf": [ + {"additionalProperties": true, "properties": {"a": {"type": "string"}}, "type": "object"} + ] + }), + ); + } + schemas.insert( + "buyer".to_owned(), + json!({"additionalProperties": true, "properties": {"email": {"type": "string"}}, "type": "object"}), + ); + json!({"components": {"schemas": schemas}}) + } + + #[test] + fn capturing_open_response_properties_keeps_unknown_keys_for_response_schemas_only() { + let mut spec = spec_with_open_response_schemas(); + + capture_open_response_properties(&mut spec).expect("schemas are present"); + + for name in OPEN_RESPONSE_SCHEMAS { + let branch = spec + .pointer(&format!("/components/schemas/{name}/allOf/0")) + .expect("branch"); + assert_eq!(branch["additionalProperties"], json!({}), "{name}"); + assert!(branch["properties"].get("a").is_some(), "{name}"); + } + assert_eq!( + spec.pointer("/components/schemas/buyer/additionalProperties"), + Some(&json!(true)), + "request-side schemas stay untouched" + ); + } + + #[test] + fn capturing_open_response_properties_fails_when_a_named_schema_disappears() { + let mut spec = spec_with_open_response_schemas(); + spec.pointer_mut("/components/schemas") + .and_then(serde_json::Value::as_object_mut) + .expect("schemas") + .remove("ssl_schema"); + + let error = capture_open_response_properties(&mut spec).expect_err("schema is missing"); + + assert!(error.to_string().contains("ssl_schema"), "{error}"); + } + #[test] fn preserving_payment_instrument_fields_adds_handler_routing_metadata() { let mut spec = json!({