Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions rust/shopping-client/openapi/shopping.oas3.json
Original file line number Diff line number Diff line change
Expand Up @@ -973,7 +973,7 @@
"$ref": "#/components/schemas/product-metadata_base_schema"
},
{
"additionalProperties": true,
"additionalProperties": {},
"properties": {
"mailbox_size_mb": {
"minimum": 0,
Expand Down Expand Up @@ -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"
Expand All @@ -2117,7 +2117,7 @@
"$ref": "#/components/schemas/product-metadata_base_schema"
},
{
"additionalProperties": true,
"additionalProperties": {},
"properties": {
"control_panel": {
"type": "string"
Expand Down Expand Up @@ -2692,7 +2692,7 @@
"$ref": "#/components/schemas/product-metadata_base_schema"
},
{
"additionalProperties": true,
"additionalProperties": {},
"properties": {
"assurance": {
"type": "string"
Expand Down Expand Up @@ -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"
Expand All @@ -3360,7 +3360,7 @@
"$ref": "#/components/schemas/variant-metadata_base_schema"
},
{
"additionalProperties": true,
"additionalProperties": {},
"properties": {
"control_panel": {
"type": "string"
Expand Down
38 changes: 38 additions & 0 deletions rust/shopping-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>>,
Expand Down
112 changes: 110 additions & 2 deletions rust/tools/generate-api-catalog/src/shopping_spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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!({
Expand Down
Loading