From b87a496576308309edb595d70c5a1ba36fe61ea3 Mon Sep 17 00:00:00 2001 From: sswaminathan Date: Tue, 8 Sep 2026 17:05:37 -0700 Subject: [PATCH 01/14] feat(shopping): add Order Management API commands Add Shopping catalog, checkout, and order commands backed by the direct Order Management Katana service. - Add configurable Shopping service URL resolution. - Request the full Shopping OAuth scope bundle in one consent flow. - Add catalog search/lookup/get and checkout create/get/update/complete. - Require idempotency keys for completion and avoid automatic replays. - Add bounded order-read polling for eventual consistency. - Document the completed-checkout read limitation and direct-service setup. - Add client, resolver, scope, command discovery, and dry-run coverage. Pending: - Register shopping.catalog:read, shopping.checkout:execute, and shopping.order:read for the CLI OAuth client. - Run authenticated Test-tier catalog and checkout checks. - Authorize and execute a real Test-tier completion smoke only when needed. - Switch to front-door routing/PAT exchange when available. Co-Authored-By: Claude --- rust/src/environments/config.rs | 16 ++ rust/src/environments/mod.rs | 2 + rust/src/environments/shopping.rs | 76 +++++++++ rust/src/main.rs | 2 + rust/src/scopes.rs | 22 +++ rust/src/shopping/catalog/get.rs | 40 +++++ rust/src/shopping/catalog/lookup.rs | 44 ++++++ rust/src/shopping/catalog/mod.rs | 18 +++ rust/src/shopping/catalog/search.rs | 42 +++++ rust/src/shopping/checkout/complete.rs | 98 ++++++++++++ rust/src/shopping/checkout/create.rs | 59 +++++++ rust/src/shopping/checkout/get.rs | 31 ++++ rust/src/shopping/checkout/mod.rs | 20 +++ rust/src/shopping/checkout/update.rs | 58 +++++++ rust/src/shopping/client.rs | 203 +++++++++++++++++++++++++ rust/src/shopping/common.rs | 146 ++++++++++++++++++ rust/src/shopping/guides/shopping.md | 80 ++++++++++ rust/src/shopping/mod.rs | 46 ++++++ rust/src/shopping/order/get.rs | 56 +++++++ rust/src/shopping/order/mod.rs | 8 + 20 files changed, 1067 insertions(+) create mode 100644 rust/src/environments/shopping.rs create mode 100644 rust/src/shopping/catalog/get.rs create mode 100644 rust/src/shopping/catalog/lookup.rs create mode 100644 rust/src/shopping/catalog/mod.rs create mode 100644 rust/src/shopping/catalog/search.rs create mode 100644 rust/src/shopping/checkout/complete.rs create mode 100644 rust/src/shopping/checkout/create.rs create mode 100644 rust/src/shopping/checkout/get.rs create mode 100644 rust/src/shopping/checkout/mod.rs create mode 100644 rust/src/shopping/checkout/update.rs create mode 100644 rust/src/shopping/client.rs create mode 100644 rust/src/shopping/common.rs create mode 100644 rust/src/shopping/guides/shopping.md create mode 100644 rust/src/shopping/mod.rs create mode 100644 rust/src/shopping/order/get.rs create mode 100644 rust/src/shopping/order/mod.rs diff --git a/rust/src/environments/config.rs b/rust/src/environments/config.rs index 951d6d25..13b434cf 100644 --- a/rust/src/environments/config.rs +++ b/rust/src/environments/config.rs @@ -69,6 +69,15 @@ pub struct GddyEnvConfig { default_fn = default_devx_core_url )] pub devx_core_url: String, + + /// Base URL for the Order Management Shopping API. This direct-service + /// endpoint is configured per environment until Shopping reaches the + /// public front door. Shell overrides are applied by `shopping_url`. + #[env_config( + from_toml = parse_url_from_toml, + default_fn = default_shopping_url + )] + pub shopping_url: String, } /// `name`'s `default_fn`: the field itself is never set by any real TOML/env @@ -117,6 +126,13 @@ fn default_devx_core_url(_sources: &SourceChain<'_>) -> String { String::new() } +fn default_shopping_url(_sources: &SourceChain<'_>) -> String { + // Shopping is currently exposed directly by the Order Management service, + // rather than the public front door. The resolver reports a configuration + // error until an environment supplies the explicit service URL. + String::new() +} + fn derive_account_url(env_name: &str) -> String { if env_name == "prod" { return "https://account.godaddy.com".to_owned(); diff --git a/rust/src/environments/mod.rs b/rust/src/environments/mod.rs index 3f88f97c..deca906e 100644 --- a/rust/src/environments/mod.rs +++ b/rust/src/environments/mod.rs @@ -28,6 +28,7 @@ mod catalog; mod config; mod devx_core; +mod shopping; #[cfg(test)] mod test_support; @@ -39,6 +40,7 @@ use cli_engine::environments::Environments; pub use catalog::resolve_catalog_base_url; pub use config::GddyEnvConfig; pub use devx_core::devx_core_url; +pub use shopping::shopping_url; pub const DEFAULT_ENV: &str = "prod"; diff --git a/rust/src/environments/shopping.rs b/rust/src/environments/shopping.rs new file mode 100644 index 00000000..94c6044c --- /dev/null +++ b/rust/src/environments/shopping.rs @@ -0,0 +1,76 @@ +//! Order Management Shopping API base-URL resolution per environment. + +use super::config::clean_url; +use super::{env_prefix, resolve}; + +/// Base URL for the direct Order Management Shopping API for `name`. +/// +/// Until the service is available through the public front door, configure its +/// explicit Katana endpoint in `environments.toml` as `shopping_url`. Shell +/// overrides take precedence: `_SHOPPING_URL` (for example, +/// `TEST_SHOPPING_URL`), then `SHOPPING_URL`. +pub fn shopping_url(name: &str) -> Option { + let configured = resolve(name) + .ok() + .and_then(|config| clean_url(&config.shopping_url)); + shopping_url_with(name, configured.as_deref(), |key| std::env::var(key).ok()) +} + +fn shopping_url_with( + name: &str, + configured: Option<&str>, + var: impl Fn(&str) -> Option, +) -> Option { + let prefix = env_prefix(name); + var(&format!("{prefix}_SHOPPING_URL")) + .and_then(|value| clean_url(&value)) + .or_else(|| var("SHOPPING_URL").and_then(|value| clean_url(&value))) + .or_else(|| configured.and_then(clean_url)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shopping_url_uses_the_environments_toml_value() { + assert_eq!( + shopping_url_with("test", Some(" https://shopping.example.test/ "), |_| None) + .as_deref(), + Some("https://shopping.example.test") + ); + } + + #[test] + fn shopping_url_global_override_wins_over_the_environments_toml_value() { + assert_eq!( + shopping_url_with("test", Some("https://configured.example.test"), |key| { + (key == "SHOPPING_URL").then(|| "http://localhost:8080/".to_owned()) + }) + .as_deref(), + Some("http://localhost:8080") + ); + } + + #[test] + fn shopping_url_per_environment_override_wins_over_global() { + assert_eq!( + shopping_url_with( + "test", + Some("https://configured.example.test"), + |key| match key { + "TEST_SHOPPING_URL" => Some("https://test-shopping.example.test/".to_owned()), + "SHOPPING_URL" => Some("https://shared-shopping.example.test".to_owned()), + _ => None, + } + ) + .as_deref(), + Some("https://test-shopping.example.test") + ); + } + + #[test] + fn shopping_url_requires_explicit_configuration() { + assert_eq!(shopping_url_with("prod", None, |_| None), None); + } +} diff --git a/rust/src/main.rs b/rust/src/main.rs index aef05f4f..e32fe304 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -21,6 +21,7 @@ mod platform; mod quote_cache; mod scopes; mod scopes_cmd; +mod shopping; mod summary; mod truncation; mod update; @@ -47,6 +48,7 @@ pub(crate) fn all_modules() -> Vec { pat::module(), payment_methods::module(), platform::module(), + shopping::module(), update::module(), ] } diff --git a/rust/src/scopes.rs b/rust/src/scopes.rs index 847bd3f2..d022e85b 100644 --- a/rust/src/scopes.rs +++ b/rust/src/scopes.rs @@ -126,6 +126,13 @@ declare_scopes! { EMAIL_READ => "email.mailbox:read", /// Create a mailbox (`email create`). EMAIL_CREATE => "email.mailbox:create", + + /// Browse and resolve Shopping catalog products. + SHOPPING_CATALOG_READ => "shopping.catalog:read", + /// Create, update, read, and complete Shopping checkout sessions. + SHOPPING_CHECKOUT_EXECUTE => "shopping.checkout:execute", + /// Read completed Shopping orders. + SHOPPING_ORDER_READ => "shopping.order:read", } /// A requestable scope, its human description, and whether it is requested at @@ -235,6 +242,21 @@ pub const SCOPE_REGISTRY: &[ScopeInfo] = &[ description: "Create a mailbox", default: false, }, + ScopeInfo { + scope: SHOPPING_CATALOG_READ, + description: "Browse and resolve Shopping catalog products", + default: false, + }, + ScopeInfo { + scope: SHOPPING_CHECKOUT_EXECUTE, + description: "Create, update, read, and complete Shopping checkout sessions", + default: false, + }, + ScopeInfo { + scope: SHOPPING_ORDER_READ, + description: "Read completed Shopping orders", + default: false, + }, ScopeInfo { scope: OFFLINE_ACCESS, description: "Request a refresh token", diff --git a/rust/src/shopping/catalog/get.rs b/rust/src/shopping/catalog/get.rs new file mode 100644 index 00000000..fafd2cda --- /dev/null +++ b/rust/src/shopping/catalog/get.rs @@ -0,0 +1,40 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; + +use crate::output_schema::output_schema; +use crate::shopping::SHOPPING_SCOPES; +use crate::shopping::common::{client_err, make_client, read_json}; + +output_schema!(CatalogProductOutput { + "ucp": "object"; + "product": "object"; +}); + +#[derive(Debug, Clone, clap::Args)] +struct Args { + /// Product request as raw JSON, including `id`. + #[arg(long, value_name = "JSON", required_unless_present = "file")] + body: Option, + + /// Path to a JSON product request. Takes precedence over --body. + #[arg(long, value_name = "PATH")] + file: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("get", "Get one Shopping catalog product") + .with_long("Submit a UCP product JSON object containing `id`.") + .with_system("shopping") + .with_tier(Tier::Read) + .with_scopes(SHOPPING_SCOPES) + .with_output_schema::() + .with_default_fields("product"), + |ctx, args: Args| async move { + let body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + let client = make_client(&ctx).await?; + Ok(CommandResult::new( + client.catalog_product(body).await.map_err(client_err)?, + )) + }, + ) +} diff --git a/rust/src/shopping/catalog/lookup.rs b/rust/src/shopping/catalog/lookup.rs new file mode 100644 index 00000000..edaf740a --- /dev/null +++ b/rust/src/shopping/catalog/lookup.rs @@ -0,0 +1,44 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; + +use crate::output_schema::output_schema; +use crate::shopping::SHOPPING_SCOPES; +use crate::shopping::common::{client_err, make_client, read_json}; + +output_schema!(CatalogLookupOutput { + "ucp": "object"; + "products": "[]object"; + "messages": "[]object"; +}); + +#[derive(Debug, Clone, clap::Args)] +struct Args { + /// Lookup request as raw JSON, including the `ids` array. + #[arg(long, value_name = "JSON", required_unless_present = "file")] + body: Option, + + /// Path to a JSON lookup request. Takes precedence over --body. + #[arg(long, value_name = "PATH")] + file: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("lookup", "Resolve known Shopping catalog IDs") + .with_long( + "Submit a UCP catalog-lookup JSON object containing one or more `ids`. Unknown IDs \ + are reported in the response messages rather than failing the whole request.", + ) + .with_system("shopping") + .with_tier(Tier::Read) + .with_scopes(SHOPPING_SCOPES) + .with_output_schema::() + .with_default_fields("products,messages"), + |ctx, args: Args| async move { + let body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + let client = make_client(&ctx).await?; + Ok(CommandResult::new( + client.catalog_lookup(body).await.map_err(client_err)?, + )) + }, + ) +} diff --git a/rust/src/shopping/catalog/mod.rs b/rust/src/shopping/catalog/mod.rs new file mode 100644 index 00000000..e05417ea --- /dev/null +++ b/rust/src/shopping/catalog/mod.rs @@ -0,0 +1,18 @@ +mod get; +mod lookup; +mod search; + +use cli_engine::{GroupSpec, RuntimeGroupSpec}; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new( + GroupSpec::new("catalog", "Browse and resolve Shopping catalog products").with_long( + "Search the live Shopping catalog, resolve known product IDs, and retrieve product details. \ + Every command requests the complete Shopping OAuth scope bundle so a catalog-to-checkout \ + workflow needs only one consent flow.", + ), + ) + .with_command(search::command()) + .with_command(lookup::command()) + .with_command(get::command()) +} diff --git a/rust/src/shopping/catalog/search.rs b/rust/src/shopping/catalog/search.rs new file mode 100644 index 00000000..f1be4106 --- /dev/null +++ b/rust/src/shopping/catalog/search.rs @@ -0,0 +1,42 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; + +use crate::output_schema::output_schema; +use crate::shopping::SHOPPING_SCOPES; +use crate::shopping::common::{client_err, make_client, read_json}; + +output_schema!(CatalogSearchOutput { + "ucp": "object"; + "products": "[]object"; + "pagination": "object", optional; + "messages": "[]object"; +}); + +#[derive(Debug, Clone, clap::Args)] +struct Args { + /// Search request as raw JSON. Use `{}` to browse all products. + #[arg(long, value_name = "JSON", required_unless_present = "file")] + body: Option, + + /// Path to a JSON search request. Takes precedence over --body. + #[arg(long, value_name = "PATH")] + file: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("search", "Search the Shopping catalog") + .with_long("Submit a UCP catalog-search JSON object. Use `{}` to browse all products.") + .with_system("shopping") + .with_tier(Tier::Read) + .with_scopes(SHOPPING_SCOPES) + .with_output_schema::() + .with_default_fields("products,pagination,messages"), + |ctx, args: Args| async move { + let body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + let client = make_client(&ctx).await?; + Ok(CommandResult::new( + client.catalog_search(body).await.map_err(client_err)?, + )) + }, + ) +} diff --git a/rust/src/shopping/checkout/complete.rs b/rust/src/shopping/checkout/complete.rs new file mode 100644 index 00000000..efaa7226 --- /dev/null +++ b/rust/src/shopping/checkout/complete.rs @@ -0,0 +1,98 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use serde_json::json; + +use crate::next_action::next_action; +use crate::shopping::SHOPPING_SCOPES; +use crate::shopping::common::{ + client_err, has_conflicting_checkout_id, make_client, read_json, require_idempotency_key, + wait_duration, wait_for_order, +}; + +#[derive(Debug, Clone, clap::Args)] +struct Args { + /// Checkout session ID. + #[arg(value_name = "CHECKOUT_ID")] + id: String, + + /// Completion request as raw JSON. It must include idempotency_key. + #[arg(long, value_name = "JSON", required_unless_present = "file")] + body: Option, + + /// Path to a JSON completion request. Takes precedence over --body. + #[arg(long, value_name = "PATH")] + file: Option, + + /// Wait for the completed order to become visible in the order read model. + #[arg(long)] + wait_for_order: bool, + + /// Maximum seconds to wait for order visibility (1-60, default 15). + #[arg(long, value_name = "SECONDS", requires = "wait_for_order")] + timeout: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("complete", "Complete a Shopping checkout and place an order") + .with_long( + "Places a real order. The JSON request must include a selected saved payment instrument \ + and a non-empty idempotency_key. The CLI never retries completion automatically. Use \ + --wait-for-order to poll the eventually consistent order read model after success.", + ) + .with_system("shopping") + .with_tier(Tier::Mutate) + .mutates(true) + .handles_dry_run(true) + .with_scopes(SHOPPING_SCOPES) + .auth_optional(), + |ctx, args: Args| async move { + let body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + require_idempotency_key(&body)?; + if has_conflicting_checkout_id(&body, &args.id) { + return Err(crate::error::GddyError::validation( + "checkout ID in request body conflicts with CHECKOUT_ID", + ) + .into_cli_error()); + } + if ctx.dry_run() { + return Ok(CommandResult::new(json!({ + "action": "dry-run: would complete checkout", + "id": args.id, + "body": body, + }))); + } + + let client = make_client(&ctx).await?; + let completion = client.complete_checkout(&args.id, body).await.map_err(client_err)?; + let order_id = completion + .pointer("/order/id") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + + if args.wait_for_order { + let order_id = order_id.ok_or_else(|| { + crate::error::GddyError::unexpected( + "completion response did not include order.id for --wait-for-order", + ) + .into_cli_error() + })?; + let timeout = wait_duration(args.timeout.as_deref())?; + let (order, attempts) = wait_for_order(&client, &order_id, timeout).await?; + return Ok(CommandResult::new(json!({ + "checkout": completion, + "order": order, + "order_read_attempts": attempts, + }))); + } + + let mut result = CommandResult::new(completion); + if let Some(order_id) = order_id { + result = result.with_next_actions(vec![next_action( + format!("shopping order get {order_id} --wait"), + "Read the completed order after it becomes visible", + )]); + } + Ok(result) + }, + ) +} diff --git a/rust/src/shopping/checkout/create.rs b/rust/src/shopping/checkout/create.rs new file mode 100644 index 00000000..c66c3d98 --- /dev/null +++ b/rust/src/shopping/checkout/create.rs @@ -0,0 +1,59 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; + +use crate::output_schema::output_schema; +use crate::shopping::SHOPPING_SCOPES; +use crate::shopping::common::{client_err, make_client, read_json}; + +output_schema!(CheckoutOutput { + "ucp": "object"; + "id": "string"; + "status": "string"; + "line_items": "[]object"; + "totals": "[]object"; + "payment": "object", optional; + "messages": "[]object"; + "action": "string", optional; + "body": "object", optional; +}); + +#[derive(Debug, Clone, clap::Args)] +struct Args { + /// Checkout-create request as raw JSON. + #[arg(long, value_name = "JSON", required_unless_present = "file")] + body: Option, + + /// Path to a JSON checkout-create request. Takes precedence over --body. + #[arg(long, value_name = "PATH")] + file: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("create", "Create a Shopping checkout session") + .with_long( + "Create a checkout from a UCP JSON object. This mutates the remote Shopping \ + service but does not purchase; use `checkout complete` only after review.", + ) + .with_system("shopping") + .with_tier(Tier::Mutate) + .mutates(true) + .handles_dry_run(true) + .with_scopes(SHOPPING_SCOPES) + .auth_optional() + .with_output_schema::() + .with_default_fields("id,status,line_items,totals,messages,action,body"), + |ctx, args: Args| async move { + let body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + if ctx.dry_run() { + return Ok(CommandResult::new(serde_json::json!({ + "action": "dry-run: would create checkout", + "body": body, + }))); + } + let client = make_client(&ctx).await?; + Ok(CommandResult::new( + client.create_checkout(body).await.map_err(client_err)?, + )) + }, + ) +} diff --git a/rust/src/shopping/checkout/get.rs b/rust/src/shopping/checkout/get.rs new file mode 100644 index 00000000..061f06f3 --- /dev/null +++ b/rust/src/shopping/checkout/get.rs @@ -0,0 +1,31 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; + +use crate::shopping::SHOPPING_SCOPES; +use crate::shopping::common::{client_err, make_client}; + +#[derive(Debug, Clone, clap::Args)] +struct Args { + /// Checkout session ID. + #[arg(value_name = "CHECKOUT_ID")] + id: String, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("get", "Get an open Shopping checkout session") + .with_long( + "Get an open checkout session. Do not use after completion: the current Order \ + Management service reconstructs it from an open basket. Use `shopping order get` \ + with the order ID returned by completion instead.", + ) + .with_system("shopping") + .with_tier(Tier::Read) + .with_scopes(SHOPPING_SCOPES), + |ctx, args: Args| async move { + let client = make_client(&ctx).await?; + Ok(CommandResult::new( + client.get_checkout(&args.id).await.map_err(client_err)?, + )) + }, + ) +} diff --git a/rust/src/shopping/checkout/mod.rs b/rust/src/shopping/checkout/mod.rs new file mode 100644 index 00000000..a3d90d8f --- /dev/null +++ b/rust/src/shopping/checkout/mod.rs @@ -0,0 +1,20 @@ +mod complete; +mod create; +mod get; +mod update; + +use cli_engine::{GroupSpec, RuntimeGroupSpec}; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new( + GroupSpec::new("checkout", "Create and manage Shopping checkout sessions").with_long( + "Create, update, and complete UCP checkout sessions. Completion places a real order. \ + A completed checkout must be followed with `shopping order get`, not `checkout get`, \ + because the current service reconstructs checkout reads from its open basket.", + ), + ) + .with_command(create::command()) + .with_command(get::command()) + .with_command(update::command()) + .with_command(complete::command()) +} diff --git a/rust/src/shopping/checkout/update.rs b/rust/src/shopping/checkout/update.rs new file mode 100644 index 00000000..c1970417 --- /dev/null +++ b/rust/src/shopping/checkout/update.rs @@ -0,0 +1,58 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; + +use crate::shopping::SHOPPING_SCOPES; +use crate::shopping::common::{client_err, has_conflicting_checkout_id, make_client, read_json}; + +#[derive(Debug, Clone, clap::Args)] +struct Args { + /// Checkout session ID. + #[arg(value_name = "CHECKOUT_ID")] + id: String, + + /// Full checkout replacement as raw JSON. + #[arg(long, value_name = "JSON", required_unless_present = "file")] + body: Option, + + /// Path to a JSON checkout replacement. Takes precedence over --body. + #[arg(long, value_name = "PATH")] + file: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("update", "Fully replace a Shopping checkout") + .with_long( + "Replace checkout fields with a UCP JSON object. The request must include \ + line_items; use an empty array only to deliberately clear the cart.", + ) + .with_system("shopping") + .with_tier(Tier::Mutate) + .mutates(true) + .handles_dry_run(true) + .with_scopes(SHOPPING_SCOPES) + .auth_optional(), + |ctx, args: Args| async move { + let body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + if has_conflicting_checkout_id(&body, &args.id) { + return Err(crate::error::GddyError::validation( + "checkout ID in request body conflicts with CHECKOUT_ID", + ) + .into_cli_error()); + } + if ctx.dry_run() { + return Ok(CommandResult::new(serde_json::json!({ + "action": "dry-run: would update checkout", + "id": args.id, + "body": body, + }))); + } + let client = make_client(&ctx).await?; + Ok(CommandResult::new( + client + .update_checkout(&args.id, body) + .await + .map_err(client_err)?, + )) + }, + ) +} diff --git a/rust/src/shopping/client.rs b/rust/src/shopping/client.rs new file mode 100644 index 00000000..cac3c151 --- /dev/null +++ b/rust/src/shopping/client.rs @@ -0,0 +1,203 @@ +use std::time::Duration; + +use reqwest::{Client, Method}; +use serde_json::{Value, json}; + +use crate::application::client::make_http_client; + +const BASE_PATH: &str = "/v1/shopping"; + +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + #[error("HTTP error {status}: {body}")] + Http { + status: u16, + body: String, + retry_after: Option, + }, + #[error("network error: {0}")] + Network(#[from] reqwest::Error), +} + +impl ClientError { + pub fn is_retryable_order_read(&self) -> bool { + matches!(self, Self::Network(_)) + || matches!( + self, + Self::Http { + status: 404 | 429 | 500..=599, + .. + } + ) + } + + pub fn retry_after(&self) -> Option { + match self { + Self::Http { retry_after, .. } => *retry_after, + Self::Network(_) => None, + } + } +} + +impl From for crate::error::GddyError { + fn from(value: ClientError) -> Self { + match value { + ClientError::Http { status, body, .. } => Self::from_http(status, body, "shopping"), + ClientError::Network(error) => { + Self::network(format!("network error: {error}")).with_system("shopping") + } + } + } +} + +pub struct ShoppingClient { + client: Client, + base_url: String, + token: String, +} + +impl ShoppingClient { + pub fn new(base_url: impl Into, token: impl Into) -> Self { + Self { + client: make_http_client(), + base_url: base_url.into(), + token: token.into(), + } + } + + fn url(&self, path: &str) -> String { + format!("{}{BASE_PATH}{path}", self.base_url) + } + + async fn send_json( + &self, + method: Method, + path: &str, + body: Option, + ) -> Result { + let mut request = self + .client + .request(method, self.url(path)) + .bearer_auth(&self.token) + .header("x-request-id", uuid::Uuid::new_v4().to_string()); + if let Some(body) = body { + request = request.json(&body); + } + let request = request.build()?; + cli_engine::transport::debug_log_reqwest_request(&request); + let response = self.client.execute(request).await?; + let status = response.status(); + let headers = response.headers().clone(); + let retry_after = headers + .get(reqwest::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .map(Duration::from_secs); + let bytes = response.bytes().await?; + cli_engine::transport::debug_log_reqwest_response(status, &headers, &bytes); + + let status = status.as_u16(); + if status == 204 || bytes.is_empty() { + return if (200..300).contains(&status) { + Ok(json!(null)) + } else { + Err(ClientError::Http { + status, + body: String::new(), + retry_after, + }) + }; + } + if !(200..300).contains(&status) { + return Err(ClientError::Http { + status, + body: String::from_utf8_lossy(&bytes).into_owned(), + retry_after, + }); + } + serde_json::from_slice(&bytes).map_err(|error| ClientError::Http { + status, + body: format!( + "invalid JSON response: {error} (body: {})", + String::from_utf8_lossy(&bytes) + ), + retry_after: None, + }) + } + + pub async fn catalog_search(&self, body: Value) -> Result { + self.send_json(Method::POST, "/catalog/search", Some(body)) + .await + } + + pub async fn catalog_lookup(&self, body: Value) -> Result { + self.send_json(Method::POST, "/catalog/lookup", Some(body)) + .await + } + + pub async fn catalog_product(&self, body: Value) -> Result { + self.send_json(Method::POST, "/catalog/product", Some(body)) + .await + } + + pub async fn create_checkout(&self, body: Value) -> Result { + self.send_json(Method::POST, "/checkout-sessions", Some(body)) + .await + } + + pub async fn get_checkout(&self, id: &str) -> Result { + self.send_json(Method::GET, &format!("/checkout-sessions/{id}"), None) + .await + } + + pub async fn update_checkout(&self, id: &str, body: Value) -> Result { + self.send_json(Method::PUT, &format!("/checkout-sessions/{id}"), Some(body)) + .await + } + + pub async fn complete_checkout(&self, id: &str, body: Value) -> Result { + self.send_json( + Method::POST, + &format!("/checkout-sessions/{id}/complete"), + Some(body), + ) + .await + } + + pub async fn get_order(&self, id: &str) -> Result { + self.send_json(Method::GET, &format!("/orders/{id}"), None) + .await + } +} + +#[cfg(test)] +mod tests { + use httpmock::prelude::*; + use serde_json::json; + + use super::*; + + fn client(base_url: &str) -> ShoppingClient { + ShoppingClient::new(base_url, "test-token") + } + + #[tokio::test] + async fn surfaces_order_not_found_as_retryable() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET).path("/v1/shopping/orders/123"); + then.status(404) + .json_body(json!({ "error": "order_not_found" })); + }) + .await; + + let error = client(&server.base_url()) + .get_order("123") + .await + .expect_err("404 is an error"); + + mock.assert_async().await; + assert!(error.is_retryable_order_read()); + } +} diff --git a/rust/src/shopping/common.rs b/rust/src/shopping/common.rs new file mode 100644 index 00000000..a5105f8c --- /dev/null +++ b/rust/src/shopping/common.rs @@ -0,0 +1,146 @@ +use std::time::{Duration, Instant}; + +use cli_engine::{CliCoreError, CommandContext, Result}; +use serde_json::Value; + +use crate::error::GddyError; +use crate::shopping::SHOPPING_SCOPES; +use crate::shopping::client::{ClientError, ShoppingClient}; + +pub(crate) async fn make_client(ctx: &CommandContext) -> Result { + let required: Vec = SHOPPING_SCOPES + .iter() + .map(|scope| (*scope).to_owned()) + .collect(); + let token = ctx.credential_with_scopes(&required).await?.token; + let base_url = crate::environments::shopping_url(&ctx.middleware.env).ok_or_else(|| { + GddyError::config(format!( + "Shopping API URL is not configured for environment {:?}. Set shopping_url in \ + ~/.config/gddy/environments.toml, or set {}_SHOPPING_URL or SHOPPING_URL.", + ctx.middleware.env, + crate::environments::env_prefix(&ctx.middleware.env) + )) + .into_cli_error() + })?; + Ok(ShoppingClient::new(base_url, token)) +} + +pub(crate) fn client_err(error: ClientError) -> CliCoreError { + GddyError::from(error).into_cli_error() +} + +pub(crate) fn read_json( + body: Option<&str>, + file: Option<&str>, + expected: &'static str, +) -> Result { + let raw = if let Some(path) = file { + std::fs::read_to_string(path).map_err(|error| { + GddyError::validation(format!("failed to read JSON file {path:?}: {error}")) + .into_cli_error() + })? + } else { + body.unwrap_or_default().to_owned() + }; + let value: Value = serde_json::from_str(&raw).map_err(|error| { + GddyError::validation(format!("invalid JSON request body: {error}")).into_cli_error() + })?; + let valid = match expected { + "object" => value.is_object(), + "array" => value.is_array(), + _ => false, + }; + if valid { + Ok(value) + } else { + Err( + GddyError::validation(format!("request body must be a JSON {expected}")) + .into_cli_error(), + ) + } +} + +pub(crate) fn has_conflicting_checkout_id(body: &Value, id: &str) -> bool { + ["id", "checkout_id"] + .iter() + .filter_map(|key| body.get(*key).and_then(Value::as_str)) + .any(|body_id| body_id != id) +} + +pub(crate) fn require_idempotency_key(body: &Value) -> Result<()> { + if body + .get("idempotency_key") + .and_then(Value::as_str) + .is_some_and(|key| !key.trim().is_empty()) + { + Ok(()) + } else { + Err(GddyError::validation( + "checkout completion requires a non-empty idempotency_key in the JSON body", + ) + .with_fix("Reuse this idempotency_key if a completion request times out.") + .into_cli_error()) + } +} + +pub(crate) async fn wait_for_order( + client: &ShoppingClient, + order_id: &str, + timeout: Duration, +) -> Result<(Value, usize)> { + let started = Instant::now(); + let mut attempts = 0; + let mut delay = Duration::from_secs(1); + loop { + attempts += 1; + match client.get_order(order_id).await { + Ok(order) => return Ok((order, attempts)), + Err(error) if error.is_retryable_order_read() && started.elapsed() < timeout => { + let remaining = timeout.saturating_sub(started.elapsed()); + let retry_delay = error.retry_after().unwrap_or(delay).min(remaining); + if retry_delay.is_zero() { + break; + } + tracing::debug!( + order_id, + attempts, + ?retry_delay, + "order is not visible yet; retrying" + ); + tokio::time::sleep(retry_delay).await; + delay = delay.saturating_mul(2).min(Duration::from_secs(4)); + } + Err(error) if error.is_retryable_order_read() => { + return Err(GddyError::not_found(format!( + "order {order_id:?} was not visible after {attempts} attempts over {} seconds", + timeout.as_secs_f32() + )) + .with_fix(format!("Run: gddy shopping order get {order_id} --wait")) + .into_cli_error()); + } + Err(error) => return Err(client_err(error)), + } + } + Err(GddyError::not_found(format!( + "order {order_id:?} was not visible after {attempts} attempts over {} seconds", + timeout.as_secs_f32() + )) + .with_fix(format!("Run: gddy shopping order get {order_id} --wait")) + .into_cli_error()) +} + +pub(crate) fn wait_duration(raw: Option<&str>) -> Result { + const DEFAULT: Duration = Duration::from_secs(15); + let Some(raw) = raw else { + return Ok(DEFAULT); + }; + let seconds = raw.parse::().map_err(|_| { + GddyError::validation("--timeout must be a whole number of seconds").into_cli_error() + })?; + if seconds == 0 || seconds > 60 { + return Err( + GddyError::validation("--timeout must be between 1 and 60 seconds").into_cli_error(), + ); + } + Ok(Duration::from_secs(seconds)) +} diff --git a/rust/src/shopping/guides/shopping.md b/rust/src/shopping/guides/shopping.md new file mode 100644 index 00000000..b4de8a9f --- /dev/null +++ b/rust/src/shopping/guides/shopping.md @@ -0,0 +1,80 @@ +# Shopping API + +`gddy shopping` is a direct integration with the Order Management Shopping API. +It is currently intended for configured non-production Katana environments; it +does not fall back to the GoDaddy front door. + +## Configure the direct service + +Add the service URL to `~/.config/gddy/environments.toml`: + +```toml +[test] +api_url = "https://api.test-godaddy.com" +client_id = "" +shopping_url = "https://ecommorder-order-management-mcp-test.ecommorder-test.prod.onkatana.net" +``` + +For one invocation, use `TEST_SHOPPING_URL` or `SHOPPING_URL`. Per-environment +overrides take precedence over the global variable, which takes precedence over +`shopping_url` in the TOML file. + +## Authentication + +Every Shopping command requests these OAuth scopes together, so the first command +can take the customer through one consent flow for the complete lifecycle: + +```text +shopping.catalog:read +shopping.checkout:execute +shopping.order:read +``` + +You can authenticate before running a workflow: + +```bash +gddy auth login \ + --scope shopping.catalog:read \ + --scope shopping.checkout:execute \ + --scope shopping.order:read +``` + +PATs are not currently accepted by the direct Katana service. Use OAuth until +Shopping is exposed through the front door, where PAT exchange can occur. + +## Workflow + +Use raw JSON (`--body`) or a JSON document (`--file`) for request bodies: + +```bash +gddy --env test shopping catalog search --body '{}' +gddy --env test shopping catalog lookup --body '{"ids":["nes-wsb-vnext-tier1"]}' +gddy --env test shopping checkout create --file create-checkout.json +gddy --env test shopping checkout update --file update-checkout.json +``` + +Checkout updates use full-replacement `PUT` requests. PATCH is intentionally not +exposed by this CLI yet. + +## Completing a checkout + +`checkout complete` places a real order. Its body must include a selected saved +payment instrument and a caller-owned, non-empty `idempotency_key`. Never create a +new idempotency key when retrying an uncertain completion; reuse the original key. + +```bash +gddy --env test shopping checkout complete \ + --file complete-checkout.json --wait-for-order +``` + +Order read models are eventually consistent and normally become visible 3–10 +seconds after completion. `--wait-for-order` polls the returned order ID for up to +15 seconds by default. You can also run: + +```bash +gddy --env test shopping order get --wait --timeout 15 +``` + +Do not call `shopping checkout get` after completion: the current service reads the +underlying open basket and does not accurately represent completed checkouts. Use +`shopping order get` instead. diff --git a/rust/src/shopping/mod.rs b/rust/src/shopping/mod.rs new file mode 100644 index 00000000..1a2bf32e --- /dev/null +++ b/rust/src/shopping/mod.rs @@ -0,0 +1,46 @@ +pub mod client; + +mod catalog; +mod checkout; +mod common; +mod order; + +use cli_engine::{GroupSpec, Module, RuntimeGroupSpec}; + +use crate::scopes::{SHOPPING_CATALOG_READ, SHOPPING_CHECKOUT_EXECUTE, SHOPPING_ORDER_READ}; + +/// Every Shopping operation requests the full lifecycle scope bundle at once. +/// This deliberately avoids disruptive OAuth consent/step-up during the common +/// catalog → checkout → order workflow. +pub(crate) const SHOPPING_SCOPES: &[&str] = &[ + SHOPPING_CATALOG_READ, + SHOPPING_CHECKOUT_EXECUTE, + SHOPPING_ORDER_READ, +]; + +pub fn module() -> Module { + Module::new("Shopping", |_ctx| { + RuntimeGroupSpec::new( + GroupSpec::new("shopping", "Browse catalog products and complete purchases").with_long( + "Use the direct Order Management Shopping API integration. Every \ + command requests catalog, checkout, and order OAuth scopes together, allowing a \ + single consent flow for the catalog → checkout → order lifecycle.\n\ + \n\ + The direct Katana endpoint must be configured with shopping_url in \ + ~/.config/gddy/environments.toml (or SHOPPING_URL). PATs do not work against \ + this direct service until front-door token exchange is available.\n\ + \n\ + checkout complete places a real order and must include an idempotency_key. Follow \ + completion with `shopping order get --wait`; checkout get is not valid \ + for completed sessions.", + ), + ) + .with_group(catalog::group()) + .with_group(checkout::group()) + .with_group(order::group()) + }) + .with_guides_from_markdown([( + "shopping.md", + include_bytes!("guides/shopping.md").as_slice(), + )]) +} diff --git a/rust/src/shopping/order/get.rs b/rust/src/shopping/order/get.rs new file mode 100644 index 00000000..990ba52e --- /dev/null +++ b/rust/src/shopping/order/get.rs @@ -0,0 +1,56 @@ +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; + +use crate::output_schema::output_schema; +use crate::shopping::SHOPPING_SCOPES; +use crate::shopping::common::{client_err, make_client, wait_duration, wait_for_order}; + +output_schema!(OrderOutput { + "ucp": "object"; + "id": "string"; + "checkout_id": "string"; + "line_items": "[]object"; + "totals": "[]object"; +}); + +#[derive(Debug, Clone, clap::Args)] +struct Args { + /// Completed order ID returned by checkout completion. + #[arg(value_name = "ORDER_ID")] + id: String, + + /// Poll while the eventually consistent order read model catches up. + #[arg(long)] + wait: bool, + + /// Maximum seconds to wait for order visibility (1-60, default 15). + #[arg(long, value_name = "SECONDS", requires = "wait")] + timeout: Option, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::("get", "Get a completed Shopping order") + .with_long( + "Read a completed order. New orders are eventually consistent and usually appear \ + within 3-10 seconds; use --wait to poll up to 15 seconds by default.", + ) + .with_system("shopping") + .with_tier(Tier::Read) + .with_scopes(SHOPPING_SCOPES) + .with_output_schema::() + .with_default_fields("id,checkout_id,line_items,totals"), + |ctx, args: Args| async move { + let client = make_client(&ctx).await?; + if args.wait { + let (order, _) = + wait_for_order(&client, &args.id, wait_duration(args.timeout.as_deref())?) + .await?; + Ok(CommandResult::new(order)) + } else { + Ok(CommandResult::new( + client.get_order(&args.id).await.map_err(client_err)?, + )) + } + }, + ) +} diff --git a/rust/src/shopping/order/mod.rs b/rust/src/shopping/order/mod.rs new file mode 100644 index 00000000..b53c348a --- /dev/null +++ b/rust/src/shopping/order/mod.rs @@ -0,0 +1,8 @@ +mod get; + +use cli_engine::{GroupSpec, RuntimeGroupSpec}; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new("order", "Read completed Shopping orders")) + .with_command(get::command()) +} From 1aa6024cb1677fe98db85c61b7ef67aaa73cecd0 Mon Sep 17 00:00:00 2001 From: sswaminathan Date: Tue, 8 Sep 2026 21:45:45 -0700 Subject: [PATCH 02/14] some fixes to the response structure with variants as tables presented per returned product, including more sensible next steps --- rust/src/shopping/catalog/mod.rs | 2 +- rust/src/shopping/catalog/search.rs | 518 +++++++++++++++++++++++++++- rust/src/shopping/mod.rs | 25 +- 3 files changed, 524 insertions(+), 21 deletions(-) diff --git a/rust/src/shopping/catalog/mod.rs b/rust/src/shopping/catalog/mod.rs index e05417ea..a619f227 100644 --- a/rust/src/shopping/catalog/mod.rs +++ b/rust/src/shopping/catalog/mod.rs @@ -1,6 +1,6 @@ mod get; mod lookup; -mod search; +pub(super) mod search; use cli_engine::{GroupSpec, RuntimeGroupSpec}; diff --git a/rust/src/shopping/catalog/search.rs b/rust/src/shopping/catalog/search.rs index f1be4106..6f2f680f 100644 --- a/rust/src/shopping/catalog/search.rs +++ b/rust/src/shopping/catalog/search.rs @@ -1,14 +1,15 @@ -use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use cli_engine::{CommandResult, CommandSpec, ModuleContext, Result, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; +use crate::next_action::{next_action, required_value}; use crate::output_schema::output_schema; use crate::shopping::SHOPPING_SCOPES; use crate::shopping::common::{client_err, make_client, read_json}; output_schema!(CatalogSearchOutput { - "ucp": "object"; "products": "[]object"; "pagination": "object", optional; - "messages": "[]object"; + "messages": "[]object", optional; }); #[derive(Debug, Clone, clap::Args)] @@ -20,23 +21,522 @@ struct Args { /// Path to a JSON search request. Takes precedence over --body. #[arg(long, value_name = "PATH")] file: Option, + + /// Maximum number of products to return (1-100). + #[arg(long, value_name = "N", value_parser = clap::value_parser!(u8).range(1..=100))] + limit: Option, + + /// Opaque cursor returned by a preceding search with the same criteria. + #[arg(long, value_name = "CURSOR")] + cursor: Option, } pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("search", "Search the Shopping catalog") - .with_long("Submit a UCP catalog-search JSON object. Use `{}` to browse all products.") + .with_long( + "Search the Shopping catalog. Human output groups purchasable variants under each \ + product. Use --output json to receive the unmodified OMS response. Supply the \ + complete UCP search request with --body or --file; use `{}` to browse all products. \ + Use --limit (1-100) and the response cursor with the same search criteria to retrieve \ + later pages. Use `gddy shopping catalog get` for a selected product's complete record.", + ) .with_system("shopping") .with_tier(Tier::Read) .with_scopes(SHOPPING_SCOPES) .with_output_schema::() - .with_default_fields("products,pagination,messages"), + .with_view_id(HUMAN_VIEW_ID), |ctx, args: Args| async move { - let body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + let mut request = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + merge_pagination(&mut request, args.limit, args.cursor.as_deref())?; let client = make_client(&ctx).await?; - Ok(CommandResult::new( - client.catalog_search(body).await.map_err(client_err)?, - )) + let response = client.catalog_search(request.clone()).await.map_err(client_err)?; + let next_actions = next_actions(&response, &mut request, &ctx.middleware.env)?; + let output = if ctx.middleware.output_format == "human" { + human_response(&response, &next_actions) + } else { + response + }; + Ok(CommandResult::new(output).with_next_actions(next_actions)) }, ) } + +const HUMAN_VIEW_ID: &str = "shopping-catalog-search"; + +pub(crate) fn register_human_view(ctx: &mut ModuleContext<'_>) { + ctx.middleware_mut() + .human_views + .register_func(HUMAN_VIEW_ID, render_human); +} + +fn human_response(response: &Value, actions: &[cli_engine::NextAction]) -> Value { + let products = response + .get("products") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + let variant_count = products + .iter() + .map(|product| purchasable_variants(product).len()) + .sum::(); + let total = response + .pointer("/pagination/total_count") + .and_then(Value::as_u64) + .unwrap_or(products.len() as u64); + json!({ + "summary": format!( + "Showing {} of {total} products · {variant_count} purchasable variants", + products.len() + ), + "products": products + .iter() + .enumerate() + .map(|(index, product)| json!({ + "number": index + 1, + "title": product.get("title").and_then(Value::as_str).unwrap_or("Untitled product"), + "variants": purchasable_variants(product) + .iter() + .map(|variant| json!({ + "id": variant.get("id").and_then(Value::as_str).unwrap_or_default(), + "title": variant.get("title").and_then(Value::as_str).unwrap_or_default(), + "category": category(product), + "price": money(variant.get("price")), + "list_price": money(variant.get("list_price")), + "availability": availability(variant), + })) + .collect::>(), + })) + .collect::>(), + "next_steps": actions + .iter() + .map(|action| json!({ + "command": action.command, + "description": action.description, + })) + .collect::>(), + }) +} + +fn merge_pagination(request: &mut Value, limit: Option, cursor: Option<&str>) -> Result<()> { + if limit.is_none() && cursor.is_none() { + return Ok(()); + } + let object = request + .as_object_mut() + .expect("read_json validates the request is an object"); + let pagination = object.entry("pagination").or_insert_with(|| json!({})); + let pagination = pagination.as_object_mut().ok_or_else(|| { + crate::error::GddyError::validation("pagination must be a JSON object").into_cli_error() + })?; + + if let Some(limit) = limit { + if let Some(existing) = pagination.get("limit") + && existing.as_u64() != Some(u64::from(limit)) + { + return Err(crate::error::GddyError::validation( + "--limit conflicts with pagination.limit in the request body", + ) + .into_cli_error()); + } + pagination.insert("limit".to_owned(), json!(limit)); + } + if let Some(cursor) = cursor { + if let Some(existing) = pagination.get("cursor") + && existing.as_str() != Some(cursor) + { + return Err(crate::error::GddyError::validation( + "--cursor conflicts with pagination.cursor in the request body", + ) + .into_cli_error()); + } + pagination.insert("cursor".to_owned(), json!(cursor)); + } + Ok(()) +} + +fn next_actions( + response: &Value, + request: &mut Value, + env: &str, +) -> Result> { + let mut actions = product_actions(response, env); + actions.extend(next_page_action(response, request, env)?); + Ok(actions) +} + +fn product_actions(response: &Value, env: &str) -> Vec { + let Some(product) = response + .get("products") + .and_then(Value::as_array) + .and_then(|items| items.first()) + else { + return Vec::new(); + }; + let Some(product_id) = product.get("id").and_then(Value::as_str) else { + return Vec::new(); + }; + let product_body = json!({"id": product_id}).to_string(); + let mut actions = vec![next_action( + shopping_command(env, format!("catalog get --body '{product_body}'")), + "View the selected product's complete record", + )]; + if let Some(variant_id) = product + .get("variants") + .and_then(Value::as_array) + .and_then(|variants| variants.iter().find(|variant| is_available(variant))) + .and_then(|variant| variant.get("id")) + .and_then(Value::as_str) + { + let currency = product + .get("variants") + .and_then(Value::as_array) + .and_then(|variants| { + variants + .iter() + .find(|variant| variant.get("id").and_then(Value::as_str) == Some(variant_id)) + }) + .and_then(|variant| variant.pointer("/price/currency")) + .and_then(Value::as_str) + .unwrap_or("USD"); + let checkout_body = json!({ + "context": {"currency": currency}, + "line_items": [{"item": {"id": variant_id}, "quantity": 1}] + }) + .to_string(); + actions.push( + next_action( + shopping_command(env, format!("checkout create --body '{checkout_body}'")), + "Create a checkout with the first available variant", + ) + .with_param("variant_id", required_value(variant_id)), + ); + } + actions +} + +fn next_page_action( + response: &Value, + request: &mut Value, + env: &str, +) -> Result> { + let Some(pagination) = response.get("pagination") else { + return Ok(Vec::new()); + }; + if !pagination + .get("has_next_page") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return Ok(Vec::new()); + } + let cursor = pagination + .get("cursor") + .and_then(Value::as_str) + .ok_or_else(|| { + crate::error::GddyError::unexpected( + "catalog search indicated another page but did not return a cursor", + ) + .into_cli_error() + })?; + merge_pagination(request, None, Some(cursor))?; + let encoded_request = serde_json::to_string(request).map_err(|error| { + crate::error::GddyError::unexpected(format!("failed to encode next-page request: {error}")) + .into_cli_error() + })?; + Ok(vec![next_action( + shopping_command(env, format!("catalog search --body '{encoded_request}'")), + "Fetch the next catalog page", + )]) +} + +fn shopping_command(env: &str, command: impl AsRef) -> String { + if matches!(env, "prod" | "production") { + format!("shopping {}", command.as_ref()) + } else { + format!("--env {env} shopping {}", command.as_ref()) + } +} + +fn render_human(response: &Value) -> String { + let mut output = response + .get("summary") + .and_then(Value::as_str) + .map_or_else(String::new, |summary| format!("{summary}\n")); + for product in response + .get("products") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + output.push('\n'); + output.push_str(&format!( + "{}. {}\n{}\n", + product + .get("number") + .and_then(Value::as_u64) + .unwrap_or_default(), + product + .get("title") + .and_then(Value::as_str) + .unwrap_or("Untitled product"), + "─".repeat(72) + )); + output.push_str(&render_variants_table(product)); + } + let next_steps = response + .get("next_steps") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + if !next_steps.is_empty() { + output.push_str("\nNext steps:\n"); + for step in next_steps { + let command = step + .get("command") + .and_then(Value::as_str) + .unwrap_or_default(); + let description = step + .get("description") + .and_then(Value::as_str) + .unwrap_or_default(); + output.push_str(&format!(" {command}\n {description}\n")); + } + } + output +} + +fn render_variants_table(product: &Value) -> String { + let rows = product + .get("variants") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|variant| { + vec![ + variant + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + variant + .get("title") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + variant + .get("category") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + variant + .get("price") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + variant + .get("list_price") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + variant + .get("availability") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + ] + }) + .collect::>(); + render_table( + &[ + "ID", + "Description", + "Category", + "Your Price", + "List Price", + "Availability", + ], + &rows, + &[false, false, false, true, true, false], + ) +} + +fn render_table(headers: &[&str], rows: &[Vec], right_aligned: &[bool]) -> String { + if rows.is_empty() { + return "No purchasable variants returned.\n".to_owned(); + } + let widths = headers + .iter() + .enumerate() + .map(|(index, header)| { + rows.iter() + .filter_map(|row| row.get(index)) + .map(String::len) + .max() + .unwrap_or_default() + .max(header.len()) + }) + .collect::>(); + let mut output = format_row( + headers.iter().map(|header| (*header).to_owned()).collect(), + &widths, + right_aligned, + ); + output.push_str(&format_row( + widths.iter().map(|width| "-".repeat(*width)).collect(), + &widths, + right_aligned, + )); + for row in rows { + output.push_str(&format_row(row.clone(), &widths, right_aligned)); + } + output +} + +fn format_row(values: Vec, widths: &[usize], right_aligned: &[bool]) -> String { + let cells = values + .iter() + .enumerate() + .map(|(index, value)| { + if right_aligned.get(index).copied().unwrap_or(false) { + format!("{value:>width$}", width = widths[index]) + } else { + format!("{value:>(); + format!("{}\n", cells.join(" ")) +} + +fn purchasable_variants(product: &Value) -> Vec<&Value> { + product + .get("variants") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|variant| variant.get("id").and_then(Value::as_str).is_some()) + .collect() +} + +fn is_available(variant: &Value) -> bool { + variant + .pointer("/availability/available") + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn availability(variant: &Value) -> &'static str { + if is_available(variant) { + "Available" + } else { + "Unavailable" + } +} + +fn category(product: &Value) -> String { + product + .get("categories") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|category| category.get("value").and_then(Value::as_str)) + .collect::>() + .join(",") +} + +fn money(value: Option<&Value>) -> Option { + let amount = value?.get("amount")?.as_i64()?; + let currency = value?.get("currency")?.as_str()?; + let major = amount / 100; + let minor = amount.unsigned_abs() % 100; + Some(format!("{currency} {major}.{minor:02}")) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{human_response, merge_pagination, next_actions, render_human, shopping_command}; + + fn response() -> serde_json::Value { + json!({ + "products": [{ + "id": "product-1", + "title": "Product", + "categories": [{"value": "email"}], + "variants": [{ + "id": "product-1:1yr", + "title": "Product — 1 Year", + "availability": {"available": true}, + "price": {"amount": 7188, "currency": "USD"}, + "list_price": {"amount": 11988, "currency": "USD"} + }] + }], + "pagination": {"cursor": "next", "has_next_page": true, "total_count": 14}, + "messages": [] + }) + } + + #[test] + fn merges_cursor_and_limit_into_a_request() { + let mut request = json!({"query": "email"}); + merge_pagination(&mut request, Some(25), Some("cursor-1")).expect("valid pagination"); + assert_eq!( + request, + json!({"query": "email", "pagination": {"limit": 25, "cursor": "cursor-1"}}) + ); + } + + #[test] + fn next_actions_keep_the_selected_environment() { + let response = response(); + let mut request = json!({"pagination": {"limit": 3}}); + let actions = next_actions(&response, &mut request, "test").expect("actions"); + assert_eq!(actions.len(), 3); + assert!( + actions + .iter() + .all(|action| action.command.contains("gddy --env test")) + ); + assert!( + actions[0] + .command + .contains("catalog get --body '{\"id\":\"product-1\"}'") + ); + assert!(actions[1].command.contains("checkout create")); + assert!(actions[2].command.contains("\"cursor\":\"next\"")); + } + + #[test] + fn human_output_groups_variants_by_product_with_summary() { + let response = response(); + let actions = next_actions(&response, &mut json!({}), "test").expect("actions"); + let rendered = render_human(&human_response(&response, &actions)); + assert!(rendered.contains("Showing 1 of 14 products · 1 purchasable variants")); + assert!(rendered.contains("1. Product"), "{rendered}"); + assert!(!rendered.contains("PRODUCT ID")); + assert!( + rendered.contains("ID Description"), + "{rendered}" + ); + assert!(rendered.contains("USD 71.88")); + assert!(rendered.contains("Available")); + assert!(rendered.contains("Next steps:"), "{rendered}"); + assert!( + rendered.contains("gddy --env test shopping catalog get"), + "{rendered}" + ); + } + + #[test] + fn product_commands_preserve_non_production_environment() { + assert_eq!( + shopping_command("prod", "catalog search"), + "shopping catalog search" + ); + assert_eq!( + shopping_command("test", "catalog search"), + "--env test shopping catalog search" + ); + } +} diff --git a/rust/src/shopping/mod.rs b/rust/src/shopping/mod.rs index 1a2bf32e..20c1ea68 100644 --- a/rust/src/shopping/mod.rs +++ b/rust/src/shopping/mod.rs @@ -7,6 +7,8 @@ mod order; use cli_engine::{GroupSpec, Module, RuntimeGroupSpec}; +use crate::shopping::catalog::search::register_human_view; + use crate::scopes::{SHOPPING_CATALOG_READ, SHOPPING_CHECKOUT_EXECUTE, SHOPPING_ORDER_READ}; /// Every Shopping operation requests the full lifecycle scope bundle at once. @@ -19,20 +21,21 @@ pub(crate) const SHOPPING_SCOPES: &[&str] = &[ ]; pub fn module() -> Module { - Module::new("Shopping", |_ctx| { + Module::new("Shopping", |ctx| { + register_human_view(ctx); RuntimeGroupSpec::new( - GroupSpec::new("shopping", "Browse catalog products and complete purchases").with_long( - "Use the direct Order Management Shopping API integration. Every \ - command requests catalog, checkout, and order OAuth scopes together, allowing a \ - single consent flow for the catalog → checkout → order lifecycle.\n\ + GroupSpec::new( + "shopping", + "Browse GoDaddy products, execute checkout, and view completed orders", + ) + .with_long( + "Browse GoDaddy products, create/update/complete checkout, and view completed orders.\n\ \n\ - The direct Katana endpoint must be configured with shopping_url in \ - ~/.config/gddy/environments.toml (or SHOPPING_URL). PATs do not work against \ - this direct service until front-door token exchange is available.\n\ + Shopping commands request the required OAuth permissions together so you can \ + complete the catalog → checkout → order workflow without additional consent prompts.\n\ \n\ - checkout complete places a real order and must include an idempotency_key. Follow \ - completion with `shopping order get --wait`; checkout get is not valid \ - for completed sessions.", + Use `gddy guide shopping` for request formats, checkout-completion safety, and \ + environment configuration.", ), ) .with_group(catalog::group()) From c89c204f13897ea9d88481953522b74df1d6bd47 Mon Sep 17 00:00:00 2001 From: sswaminathan Date: Tue, 8 Sep 2026 22:52:10 -0700 Subject: [PATCH 03/14] fix(shopping): clarify catalog pagination Co-Authored-By: Claude --- rust/src/environments/config.rs | 4 +- rust/src/environments/shopping.rs | 4 +- rust/src/shopping/catalog/search.rs | 55 ++++++++++++++-------------- rust/src/shopping/guides/shopping.md | 28 +++++++++++++- 4 files changed, 58 insertions(+), 33 deletions(-) diff --git a/rust/src/environments/config.rs b/rust/src/environments/config.rs index 13b434cf..ed29564b 100644 --- a/rust/src/environments/config.rs +++ b/rust/src/environments/config.rs @@ -70,8 +70,8 @@ pub struct GddyEnvConfig { )] pub devx_core_url: String, - /// Base URL for the Order Management Shopping API. This direct-service - /// endpoint is configured per environment until Shopping reaches the + /// Base URL for the Shopping API. This direct-service endpoint is + /// configured per environment until Shopping reaches the /// public front door. Shell overrides are applied by `shopping_url`. #[env_config( from_toml = parse_url_from_toml, diff --git a/rust/src/environments/shopping.rs b/rust/src/environments/shopping.rs index 94c6044c..2abb0857 100644 --- a/rust/src/environments/shopping.rs +++ b/rust/src/environments/shopping.rs @@ -1,9 +1,9 @@ -//! Order Management Shopping API base-URL resolution per environment. +//! Shopping API base-URL resolution per environment. use super::config::clean_url; use super::{env_prefix, resolve}; -/// Base URL for the direct Order Management Shopping API for `name`. +/// Base URL for the direct Shopping API for `name`. /// /// Until the service is available through the public front door, configure its /// explicit Katana endpoint in `environments.toml` as `shopping_url`. Shell diff --git a/rust/src/shopping/catalog/search.rs b/rust/src/shopping/catalog/search.rs index 6f2f680f..6d1c216f 100644 --- a/rust/src/shopping/catalog/search.rs +++ b/rust/src/shopping/catalog/search.rs @@ -25,10 +25,6 @@ struct Args { /// Maximum number of products to return (1-100). #[arg(long, value_name = "N", value_parser = clap::value_parser!(u8).range(1..=100))] limit: Option, - - /// Opaque cursor returned by a preceding search with the same criteria. - #[arg(long, value_name = "CURSOR")] - cursor: Option, } pub(super) fn command() -> RuntimeCommandSpec { @@ -36,10 +32,11 @@ pub(super) fn command() -> RuntimeCommandSpec { CommandSpec::from_args::("search", "Search the Shopping catalog") .with_long( "Search the Shopping catalog. Human output groups purchasable variants under each \ - product. Use --output json to receive the unmodified OMS response. Supply the \ + product. Use --output json to receive the unmodified Shopping API response. Supply the \ complete UCP search request with --body or --file; use `{}` to browse all products. \ - Use --limit (1-100) and the response cursor with the same search criteria to retrieve \ - later pages. Use `gddy shopping catalog get` for a selected product's complete record.", + Place `pagination.limit` and the response cursor in the request body to retrieve later \ + pages with the same search criteria. Use `gddy shopping catalog get` for a selected \ + product's complete record.", ) .with_system("shopping") .with_tier(Tier::Read) @@ -48,7 +45,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_view_id(HUMAN_VIEW_ID), |ctx, args: Args| async move { let mut request = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; - merge_pagination(&mut request, args.limit, args.cursor.as_deref())?; + merge_pagination(&mut request, args.limit)?; let client = make_client(&ctx).await?; let response = client.catalog_search(request.clone()).await.map_err(client_err)?; let next_actions = next_actions(&response, &mut request, &ctx.middleware.env)?; @@ -94,6 +91,7 @@ fn human_response(response: &Value, actions: &[cli_engine::NextAction]) -> Value .enumerate() .map(|(index, product)| json!({ "number": index + 1, + "id": product.get("id").and_then(Value::as_str).unwrap_or_default(), "title": product.get("title").and_then(Value::as_str).unwrap_or("Untitled product"), "variants": purchasable_variants(product) .iter() @@ -118,8 +116,8 @@ fn human_response(response: &Value, actions: &[cli_engine::NextAction]) -> Value }) } -fn merge_pagination(request: &mut Value, limit: Option, cursor: Option<&str>) -> Result<()> { - if limit.is_none() && cursor.is_none() { +fn merge_pagination(request: &mut Value, limit: Option) -> Result<()> { + if limit.is_none() { return Ok(()); } let object = request @@ -141,17 +139,6 @@ fn merge_pagination(request: &mut Value, limit: Option, cursor: Option<&str> } pagination.insert("limit".to_owned(), json!(limit)); } - if let Some(cursor) = cursor { - if let Some(existing) = pagination.get("cursor") - && existing.as_str() != Some(cursor) - { - return Err(crate::error::GddyError::validation( - "--cursor conflicts with pagination.cursor in the request body", - ) - .into_cli_error()); - } - pagination.insert("cursor".to_owned(), json!(cursor)); - } Ok(()) } @@ -239,7 +226,14 @@ fn next_page_action( ) .into_cli_error() })?; - merge_pagination(request, None, Some(cursor))?; + let request = request + .as_object_mut() + .expect("read_json validates the request is an object"); + let pagination = request.entry("pagination").or_insert_with(|| json!({})); + let pagination = pagination.as_object_mut().ok_or_else(|| { + crate::error::GddyError::validation("pagination must be a JSON object").into_cli_error() + })?; + pagination.insert("cursor".to_owned(), json!(cursor)); let encoded_request = serde_json::to_string(request).map_err(|error| { crate::error::GddyError::unexpected(format!("failed to encode next-page request: {error}")) .into_cli_error() @@ -271,7 +265,7 @@ fn render_human(response: &Value) -> String { { output.push('\n'); output.push_str(&format!( - "{}. {}\n{}\n", + "{}. {} (ID: {})\n{}\n", product .get("number") .and_then(Value::as_u64) @@ -280,6 +274,10 @@ fn render_human(response: &Value) -> String { .get("title") .and_then(Value::as_str) .unwrap_or("Untitled product"), + product + .get("id") + .and_then(Value::as_str) + .unwrap_or_default(), "─".repeat(72) )); output.push_str(&render_variants_table(product)); @@ -478,9 +476,9 @@ mod tests { } #[test] - fn merges_cursor_and_limit_into_a_request() { - let mut request = json!({"query": "email"}); - merge_pagination(&mut request, Some(25), Some("cursor-1")).expect("valid pagination"); + fn merges_limit_without_changing_a_body_cursor() { + let mut request = json!({"query": "email", "pagination": {"cursor": "cursor-1"}}); + merge_pagination(&mut request, Some(25)).expect("valid pagination"); assert_eq!( request, json!({"query": "email", "pagination": {"limit": 25, "cursor": "cursor-1"}}) @@ -513,7 +511,10 @@ mod tests { let actions = next_actions(&response, &mut json!({}), "test").expect("actions"); let rendered = render_human(&human_response(&response, &actions)); assert!(rendered.contains("Showing 1 of 14 products · 1 purchasable variants")); - assert!(rendered.contains("1. Product"), "{rendered}"); + assert!( + rendered.contains("1. Product (ID: product-1)"), + "{rendered}" + ); assert!(!rendered.contains("PRODUCT ID")); assert!( rendered.contains("ID Description"), diff --git a/rust/src/shopping/guides/shopping.md b/rust/src/shopping/guides/shopping.md index b4de8a9f..b389e5fe 100644 --- a/rust/src/shopping/guides/shopping.md +++ b/rust/src/shopping/guides/shopping.md @@ -1,6 +1,6 @@ # Shopping API -`gddy shopping` is a direct integration with the Order Management Shopping API. +`gddy shopping` is a direct integration with the Shopping API. It is currently intended for configured non-production Katana environments; it does not fall back to the GoDaddy front door. @@ -47,12 +47,36 @@ Shopping is exposed through the front door, where PAT exchange can occur. Use raw JSON (`--body`) or a JSON document (`--file`) for request bodies: ```bash -gddy --env test shopping catalog search --body '{}' +gddy --env test shopping catalog search --body '{}' --limit 3 gddy --env test shopping catalog lookup --body '{"ids":["nes-wsb-vnext-tier1"]}' +gddy --env test shopping catalog get --body '{"id":"nes-wsb-vnext-tier1"}' gddy --env test shopping checkout create --file create-checkout.json gddy --env test shopping checkout update --file update-checkout.json ``` +### Catalog search and pagination + +`catalog search` displays products as numbered sections. Each section shows its product +ID, then the purchasable variants and their prices. Use the **product ID** with +`catalog get` or `catalog lookup`; use a **variant ID** in `checkout create` line items. + +`--limit` controls the number of returned **products**, not variants. A returned product +can contain multiple purchasable variants. To receive the original Shopping API response as +valid JSON—including product metadata, variants, messages, and pagination—use `--output json`: + +```bash +gddy --env test --output json shopping catalog search --body '{}' --limit 3 +``` + +Shopping API cursor pagination belongs in the request body's `pagination` object. Preserve all +original search criteria, retain the original `pagination.limit`, and replace only the +opaque `pagination.cursor` with the cursor from the preceding response: + +```bash +gddy --env test shopping catalog search \ + --body '{"pagination":{"limit":3,"cursor":""}}' +``` + Checkout updates use full-replacement `PUT` requests. PATCH is intentionally not exposed by this CLI yet. From f78253ed1828057f5b7d17dd582225275409854a Mon Sep 17 00:00:00 2001 From: sswaminathan Date: Tue, 8 Sep 2026 23:26:26 -0700 Subject: [PATCH 04/14] docs(shopping): clarify checkout workflow Clarify customer-facing Shopping API terminology, checkout request guidance, completion safety, and order retrieval behavior. Remove endpoint-override setup from the Shopping guide while retaining its actionable runtime configuration error. Co-Authored-By: Claude --- rust/src/environments/config.rs | 6 +-- rust/src/environments/shopping.rs | 6 +-- rust/src/shopping/checkout/complete.rs | 9 ++-- rust/src/shopping/checkout/create.rs | 6 ++- rust/src/shopping/checkout/get.rs | 6 +-- rust/src/shopping/checkout/mod.rs | 6 +-- rust/src/shopping/checkout/update.rs | 5 +- rust/src/shopping/guides/shopping.md | 69 ++++++++++++++------------ rust/src/shopping/order/get.rs | 6 +-- 9 files changed, 65 insertions(+), 54 deletions(-) diff --git a/rust/src/environments/config.rs b/rust/src/environments/config.rs index ed29564b..bf2022f8 100644 --- a/rust/src/environments/config.rs +++ b/rust/src/environments/config.rs @@ -127,9 +127,9 @@ fn default_devx_core_url(_sources: &SourceChain<'_>) -> String { } fn default_shopping_url(_sources: &SourceChain<'_>) -> String { - // Shopping is currently exposed directly by the Order Management service, - // rather than the public front door. The resolver reports a configuration - // error until an environment supplies the explicit service URL. + // Shopping is currently configured with an explicit service URL rather + // than a public-front-door URL. The resolver reports a configuration + // error until an environment supplies the service URL. String::new() } diff --git a/rust/src/environments/shopping.rs b/rust/src/environments/shopping.rs index 2abb0857..6f57ae80 100644 --- a/rust/src/environments/shopping.rs +++ b/rust/src/environments/shopping.rs @@ -6,9 +6,9 @@ use super::{env_prefix, resolve}; /// Base URL for the direct Shopping API for `name`. /// /// Until the service is available through the public front door, configure its -/// explicit Katana endpoint in `environments.toml` as `shopping_url`. Shell -/// overrides take precedence: `_SHOPPING_URL` (for example, -/// `TEST_SHOPPING_URL`), then `SHOPPING_URL`. +/// explicit endpoint in `environments.toml` as `shopping_url`. Shell overrides +/// take precedence: `_SHOPPING_URL` (for example, `TEST_SHOPPING_URL`), +/// then `SHOPPING_URL`. pub fn shopping_url(name: &str) -> Option { let configured = resolve(name) .ok() diff --git a/rust/src/shopping/checkout/complete.rs b/rust/src/shopping/checkout/complete.rs index efaa7226..cc7795c3 100644 --- a/rust/src/shopping/checkout/complete.rs +++ b/rust/src/shopping/checkout/complete.rs @@ -22,7 +22,7 @@ struct Args { #[arg(long, value_name = "PATH")] file: Option, - /// Wait for the completed order to become visible in the order read model. + /// Wait for the completed order to become available. #[arg(long)] wait_for_order: bool, @@ -35,9 +35,10 @@ pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("complete", "Complete a Shopping checkout and place an order") .with_long( - "Places a real order. The JSON request must include a selected saved payment instrument \ - and a non-empty idempotency_key. The CLI never retries completion automatically. Use \ - --wait-for-order to poll the eventually consistent order read model after success.", + "Places a real order. Supply the Shopping API completion request through --body or \ + --file; it must include a selected saved payment instrument and a non-empty \ + idempotency_key. The CLI never retries completion automatically. Use --wait-for-order \ + to poll until the new order becomes available after success.", ) .with_system("shopping") .with_tier(Tier::Mutate) diff --git a/rust/src/shopping/checkout/create.rs b/rust/src/shopping/checkout/create.rs index c66c3d98..d1fd5e0e 100644 --- a/rust/src/shopping/checkout/create.rs +++ b/rust/src/shopping/checkout/create.rs @@ -31,8 +31,10 @@ pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("create", "Create a Shopping checkout session") .with_long( - "Create a checkout from a UCP JSON object. This mutates the remote Shopping \ - service but does not purchase; use `checkout complete` only after review.", + "Create a checkout from a Shopping API request object. Supply it with --body or \ + --file; `gddy guide shopping` documents the required line_items format. This \ + creates a checkout but does not place an order; use `checkout complete` only after \ + reviewing the checkout.", ) .with_system("shopping") .with_tier(Tier::Mutate) diff --git a/rust/src/shopping/checkout/get.rs b/rust/src/shopping/checkout/get.rs index 061f06f3..4db70c2f 100644 --- a/rust/src/shopping/checkout/get.rs +++ b/rust/src/shopping/checkout/get.rs @@ -14,9 +14,9 @@ pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("get", "Get an open Shopping checkout session") .with_long( - "Get an open checkout session. Do not use after completion: the current Order \ - Management service reconstructs it from an open basket. Use `shopping order get` \ - with the order ID returned by completion instead.", + "Get an open checkout session. Do not use after completion: completed checkouts \ + cannot be retrieved through this command. Use `shopping order get` with the order \ + ID returned by completion instead.", ) .with_system("shopping") .with_tier(Tier::Read) diff --git a/rust/src/shopping/checkout/mod.rs b/rust/src/shopping/checkout/mod.rs index a3d90d8f..ec55fc57 100644 --- a/rust/src/shopping/checkout/mod.rs +++ b/rust/src/shopping/checkout/mod.rs @@ -8,9 +8,9 @@ use cli_engine::{GroupSpec, RuntimeGroupSpec}; pub(super) fn group() -> RuntimeGroupSpec { RuntimeGroupSpec::new( GroupSpec::new("checkout", "Create and manage Shopping checkout sessions").with_long( - "Create, update, and complete UCP checkout sessions. Completion places a real order. \ - A completed checkout must be followed with `shopping order get`, not `checkout get`, \ - because the current service reconstructs checkout reads from its open basket.", + "Create, update, and complete Shopping checkout sessions. Completion places a real \ + order. For a completed checkout, use `shopping order get` with the returned order ID; \ + `checkout get` is for open checkout sessions only.", ), ) .with_command(create::command()) diff --git a/rust/src/shopping/checkout/update.rs b/rust/src/shopping/checkout/update.rs index c1970417..e49680db 100644 --- a/rust/src/shopping/checkout/update.rs +++ b/rust/src/shopping/checkout/update.rs @@ -22,8 +22,9 @@ pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("update", "Fully replace a Shopping checkout") .with_long( - "Replace checkout fields with a UCP JSON object. The request must include \ - line_items; use an empty array only to deliberately clear the cart.", + "Fully replace a checkout with a Shopping API request object supplied through \ + --body or --file. The request must include line_items; use an empty array only to \ + deliberately clear the cart. `gddy guide shopping` documents the request format.", ) .with_system("shopping") .with_tier(Tier::Mutate) diff --git a/rust/src/shopping/guides/shopping.md b/rust/src/shopping/guides/shopping.md index b389e5fe..35dca828 100644 --- a/rust/src/shopping/guides/shopping.md +++ b/rust/src/shopping/guides/shopping.md @@ -1,23 +1,6 @@ # Shopping API -`gddy shopping` is a direct integration with the Shopping API. -It is currently intended for configured non-production Katana environments; it -does not fall back to the GoDaddy front door. - -## Configure the direct service - -Add the service URL to `~/.config/gddy/environments.toml`: - -```toml -[test] -api_url = "https://api.test-godaddy.com" -client_id = "" -shopping_url = "https://ecommorder-order-management-mcp-test.ecommorder-test.prod.onkatana.net" -``` - -For one invocation, use `TEST_SHOPPING_URL` or `SHOPPING_URL`. Per-environment -overrides take precedence over the global variable, which takes precedence over -`shopping_url` in the TOML file. +`gddy shopping` integrates with the Shopping API. ## Authentication @@ -39,21 +22,31 @@ gddy auth login \ --scope shopping.order:read ``` -PATs are not currently accepted by the direct Katana service. Use OAuth until -Shopping is exposed through the front door, where PAT exchange can occur. +PATs are not currently accepted by the configured Shopping API endpoint. Use OAuth +until Shopping is exposed through the front door, where PAT exchange can occur. ## Workflow -Use raw JSON (`--body`) or a JSON document (`--file`) for request bodies: +Use `--body` for a small inline JSON request or `--file` for a reusable JSON document. +`--file` takes precedence over `--body`. The Shopping API uses nested checkout objects, so +checkout create, update, and complete requests remain JSON documents instead of a long list +of CLI flags. Use `gddy shopping --help` for command-specific requirements; the +examples below show the request fields required for common checkout operations. ```bash gddy --env test shopping catalog search --body '{}' --limit 3 gddy --env test shopping catalog lookup --body '{"ids":["nes-wsb-vnext-tier1"]}' gddy --env test shopping catalog get --body '{"id":"nes-wsb-vnext-tier1"}' -gddy --env test shopping checkout create --file create-checkout.json +gddy --env test shopping checkout create --body '{"context":{"currency":"USD"},"line_items":[{"item":{"id":"nes-wsb-vnext-tier1"},"quantity":1}]}' gddy --env test shopping checkout update --file update-checkout.json ``` +Use a variant ID selected from `catalog search` as `line_items[].item.id`; product IDs are +for catalog lookup. For a full checkout update, start with the open checkout returned by +`shopping checkout get `, edit the complete desired state, and send it with +`--file`. `update` replaces the checkout with the supplied document, so omitted fields may +be removed. An empty `line_items` array deliberately clears the cart. + ### Catalog search and pagination `catalog search` displays products as numbered sections. Each section shows its product @@ -82,23 +75,37 @@ exposed by this CLI yet. ## Completing a checkout -`checkout complete` places a real order. Its body must include a selected saved -payment instrument and a caller-owned, non-empty `idempotency_key`. Never create a -new idempotency key when retrying an uncertain completion; reuse the original key. +`checkout complete` places a real order. Its Shopping API request must include a selected +saved payment instrument and a caller-owned, non-empty `idempotency_key`. Use the checkout's +`payment.instruments` list to select the saved instrument: mark exactly one entry with +`"selected": true`. Never create a new idempotency key when retrying an uncertain completion; +reuse the original key only for the same intended purchase. + +```json +{ + "payment": { + "instruments": [ + { + "id": "", + "selected": true + } + ] + }, + "idempotency_key": "" +} +``` ```bash gddy --env test shopping checkout complete \ --file complete-checkout.json --wait-for-order ``` -Order read models are eventually consistent and normally become visible 3–10 -seconds after completion. `--wait-for-order` polls the returned order ID for up to -15 seconds by default. You can also run: +New orders normally become available 3–10 seconds after completion. `--wait-for-order` polls +the returned order ID for up to 15 seconds by default. You can also run: ```bash gddy --env test shopping order get --wait --timeout 15 ``` -Do not call `shopping checkout get` after completion: the current service reads the -underlying open basket and does not accurately represent completed checkouts. Use -`shopping order get` instead. +After completion, use `shopping order get` with the returned order ID. `shopping checkout get` +is for open checkout sessions only. diff --git a/rust/src/shopping/order/get.rs b/rust/src/shopping/order/get.rs index 990ba52e..84c672cc 100644 --- a/rust/src/shopping/order/get.rs +++ b/rust/src/shopping/order/get.rs @@ -18,7 +18,7 @@ struct Args { #[arg(value_name = "ORDER_ID")] id: String, - /// Poll while the eventually consistent order read model catches up. + /// Poll until the new order becomes available. #[arg(long)] wait: bool, @@ -31,8 +31,8 @@ pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("get", "Get a completed Shopping order") .with_long( - "Read a completed order. New orders are eventually consistent and usually appear \ - within 3-10 seconds; use --wait to poll up to 15 seconds by default.", + "Read a completed order. New orders usually become available within 3-10 seconds; \ + use --wait to poll for up to 15 seconds by default.", ) .with_system("shopping") .with_tier(Tier::Read) From 22f5dbbbcaf82720151d0cdbc3ed172ad6fc28e6 Mon Sep 17 00:00:00 2001 From: sswaminathan Date: Wed, 9 Sep 2026 12:56:58 -0700 Subject: [PATCH 05/14] interim - front door works, so removed the private api override support... and a few other tweaks --- rust/src/environments/config.rs | 16 --- rust/src/environments/mod.rs | 2 - rust/src/environments/shopping.rs | 76 ----------- rust/src/shopping/checkout/complete.rs | 61 ++++----- rust/src/shopping/checkout/get.rs | 178 ++++++++++++++++++++++++- rust/src/shopping/checkout/mod.rs | 2 +- rust/src/shopping/client.rs | 8 ++ rust/src/shopping/common.rs | 141 ++++++++++++++++---- rust/src/shopping/guides/shopping.md | 26 ++-- rust/src/shopping/mod.rs | 9 +- rust/src/shopping/order/get.rs | 5 +- 11 files changed, 344 insertions(+), 180 deletions(-) delete mode 100644 rust/src/environments/shopping.rs diff --git a/rust/src/environments/config.rs b/rust/src/environments/config.rs index bf2022f8..951d6d25 100644 --- a/rust/src/environments/config.rs +++ b/rust/src/environments/config.rs @@ -69,15 +69,6 @@ pub struct GddyEnvConfig { default_fn = default_devx_core_url )] pub devx_core_url: String, - - /// Base URL for the Shopping API. This direct-service endpoint is - /// configured per environment until Shopping reaches the - /// public front door. Shell overrides are applied by `shopping_url`. - #[env_config( - from_toml = parse_url_from_toml, - default_fn = default_shopping_url - )] - pub shopping_url: String, } /// `name`'s `default_fn`: the field itself is never set by any real TOML/env @@ -126,13 +117,6 @@ fn default_devx_core_url(_sources: &SourceChain<'_>) -> String { String::new() } -fn default_shopping_url(_sources: &SourceChain<'_>) -> String { - // Shopping is currently configured with an explicit service URL rather - // than a public-front-door URL. The resolver reports a configuration - // error until an environment supplies the service URL. - String::new() -} - fn derive_account_url(env_name: &str) -> String { if env_name == "prod" { return "https://account.godaddy.com".to_owned(); diff --git a/rust/src/environments/mod.rs b/rust/src/environments/mod.rs index deca906e..3f88f97c 100644 --- a/rust/src/environments/mod.rs +++ b/rust/src/environments/mod.rs @@ -28,7 +28,6 @@ mod catalog; mod config; mod devx_core; -mod shopping; #[cfg(test)] mod test_support; @@ -40,7 +39,6 @@ use cli_engine::environments::Environments; pub use catalog::resolve_catalog_base_url; pub use config::GddyEnvConfig; pub use devx_core::devx_core_url; -pub use shopping::shopping_url; pub const DEFAULT_ENV: &str = "prod"; diff --git a/rust/src/environments/shopping.rs b/rust/src/environments/shopping.rs deleted file mode 100644 index 6f57ae80..00000000 --- a/rust/src/environments/shopping.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! Shopping API base-URL resolution per environment. - -use super::config::clean_url; -use super::{env_prefix, resolve}; - -/// Base URL for the direct Shopping API for `name`. -/// -/// Until the service is available through the public front door, configure its -/// explicit endpoint in `environments.toml` as `shopping_url`. Shell overrides -/// take precedence: `_SHOPPING_URL` (for example, `TEST_SHOPPING_URL`), -/// then `SHOPPING_URL`. -pub fn shopping_url(name: &str) -> Option { - let configured = resolve(name) - .ok() - .and_then(|config| clean_url(&config.shopping_url)); - shopping_url_with(name, configured.as_deref(), |key| std::env::var(key).ok()) -} - -fn shopping_url_with( - name: &str, - configured: Option<&str>, - var: impl Fn(&str) -> Option, -) -> Option { - let prefix = env_prefix(name); - var(&format!("{prefix}_SHOPPING_URL")) - .and_then(|value| clean_url(&value)) - .or_else(|| var("SHOPPING_URL").and_then(|value| clean_url(&value))) - .or_else(|| configured.and_then(clean_url)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn shopping_url_uses_the_environments_toml_value() { - assert_eq!( - shopping_url_with("test", Some(" https://shopping.example.test/ "), |_| None) - .as_deref(), - Some("https://shopping.example.test") - ); - } - - #[test] - fn shopping_url_global_override_wins_over_the_environments_toml_value() { - assert_eq!( - shopping_url_with("test", Some("https://configured.example.test"), |key| { - (key == "SHOPPING_URL").then(|| "http://localhost:8080/".to_owned()) - }) - .as_deref(), - Some("http://localhost:8080") - ); - } - - #[test] - fn shopping_url_per_environment_override_wins_over_global() { - assert_eq!( - shopping_url_with( - "test", - Some("https://configured.example.test"), - |key| match key { - "TEST_SHOPPING_URL" => Some("https://test-shopping.example.test/".to_owned()), - "SHOPPING_URL" => Some("https://shared-shopping.example.test".to_owned()), - _ => None, - } - ) - .as_deref(), - Some("https://test-shopping.example.test") - ); - } - - #[test] - fn shopping_url_requires_explicit_configuration() { - assert_eq!(shopping_url_with("prod", None, |_| None), None); - } -} diff --git a/rust/src/shopping/checkout/complete.rs b/rust/src/shopping/checkout/complete.rs index cc7795c3..6e485cee 100644 --- a/rust/src/shopping/checkout/complete.rs +++ b/rust/src/shopping/checkout/complete.rs @@ -3,9 +3,10 @@ use serde_json::json; use crate::next_action::next_action; use crate::shopping::SHOPPING_SCOPES; +use crate::shopping::client::ClientError; use crate::shopping::common::{ - client_err, has_conflicting_checkout_id, make_client, read_json, require_idempotency_key, - wait_duration, wait_for_order, + ensure_completion_idempotency_key, has_conflicting_checkout_id, make_client, read_json, + require_selected_payment_instrument, }; #[derive(Debug, Clone, clap::Args)] @@ -14,21 +15,22 @@ struct Args { #[arg(value_name = "CHECKOUT_ID")] id: String, - /// Completion request as raw JSON. It must include idempotency_key. + /// Completion request as raw JSON. Omit idempotency_key to let gddy generate one. #[arg(long, value_name = "JSON", required_unless_present = "file")] body: Option, /// Path to a JSON completion request. Takes precedence over --body. #[arg(long, value_name = "PATH")] file: Option, +} - /// Wait for the completed order to become available. - #[arg(long)] - wait_for_order: bool, - - /// Maximum seconds to wait for order visibility (1-60, default 15). - #[arg(long, value_name = "SECONDS", requires = "wait_for_order")] - timeout: Option, +fn completion_error(error: ClientError, idempotency_key: &str) -> cli_engine::CliCoreError { + crate::error::GddyError::from(error) + .with_fix(format!( + "Completion may have reached Shopping. Do not retry automatically. Reuse idempotency_key \ + {idempotency_key:?} only for the same intended purchase after confirming its outcome." + )) + .into_cli_error() } pub(super) fn command() -> RuntimeCommandSpec { @@ -36,9 +38,10 @@ pub(super) fn command() -> RuntimeCommandSpec { CommandSpec::from_args::("complete", "Complete a Shopping checkout and place an order") .with_long( "Places a real order. Supply the Shopping API completion request through --body or \ - --file; it must include a selected saved payment instrument and a non-empty \ - idempotency_key. The CLI never retries completion automatically. Use --wait-for-order \ - to poll until the new order becomes available after success.", + --file; it must include a selected saved payment instrument. Supply a non-empty \ + idempotency_key to control retries, or omit it to let gddy generate and return one. \ + The CLI never retries completion automatically. Read the resulting order with \ + `shopping order get --wait`.", ) .with_system("shopping") .with_tier(Tier::Mutate) @@ -47,46 +50,36 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_scopes(SHOPPING_SCOPES) .auth_optional(), |ctx, args: Args| async move { - let body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; - require_idempotency_key(&body)?; + let mut body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; if has_conflicting_checkout_id(&body, &args.id) { return Err(crate::error::GddyError::validation( "checkout ID in request body conflicts with CHECKOUT_ID", ) .into_cli_error()); } + require_selected_payment_instrument(&body)?; + let idempotency_key = ensure_completion_idempotency_key(&mut body)?; if ctx.dry_run() { return Ok(CommandResult::new(json!({ "action": "dry-run: would complete checkout", "id": args.id, + "idempotency_key": idempotency_key, "body": body, }))); } let client = make_client(&ctx).await?; - let completion = client.complete_checkout(&args.id, body).await.map_err(client_err)?; + let completion = client.complete_checkout(&args.id, body).await.map_err(|error| { + completion_error(error, &idempotency_key) + })?; let order_id = completion .pointer("/order/id") .and_then(serde_json::Value::as_str) .map(str::to_owned); - - if args.wait_for_order { - let order_id = order_id.ok_or_else(|| { - crate::error::GddyError::unexpected( - "completion response did not include order.id for --wait-for-order", - ) - .into_cli_error() - })?; - let timeout = wait_duration(args.timeout.as_deref())?; - let (order, attempts) = wait_for_order(&client, &order_id, timeout).await?; - return Ok(CommandResult::new(json!({ - "checkout": completion, - "order": order, - "order_read_attempts": attempts, - }))); - } - - let mut result = CommandResult::new(completion); + let mut result = CommandResult::new(json!({ + "idempotency_key": idempotency_key, + "completion": completion, + })); if let Some(order_id) = order_id { result = result.with_next_actions(vec![next_action( format!("shopping order get {order_id} --wait"), diff --git a/rust/src/shopping/checkout/get.rs b/rust/src/shopping/checkout/get.rs index 4db70c2f..5ef45920 100644 --- a/rust/src/shopping/checkout/get.rs +++ b/rust/src/shopping/checkout/get.rs @@ -1,8 +1,17 @@ -use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use cli_engine::{CommandResult, CommandSpec, ModuleContext, Result, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; use crate::shopping::SHOPPING_SCOPES; use crate::shopping::common::{client_err, make_client}; +const HUMAN_VIEW_ID: &str = "shopping-checkout-get"; + +pub(crate) fn register_human_view(ctx: &mut ModuleContext<'_>) { + ctx.middleware_mut() + .human_views + .register_func(HUMAN_VIEW_ID, render_human); +} + #[derive(Debug, Clone, clap::Args)] struct Args { /// Checkout session ID. @@ -20,12 +29,169 @@ pub(super) fn command() -> RuntimeCommandSpec { ) .with_system("shopping") .with_tier(Tier::Read) - .with_scopes(SHOPPING_SCOPES), + .with_scopes(SHOPPING_SCOPES) + .with_view_id(HUMAN_VIEW_ID), |ctx, args: Args| async move { - let client = make_client(&ctx).await?; - Ok(CommandResult::new( - client.get_checkout(&args.id).await.map_err(client_err)?, - )) + let checkout = client_response(&ctx, &args.id).await?; + let output = if ctx.middleware.output_format == "human" { + human_response(&checkout) + } else { + checkout + }; + Ok(CommandResult::new(output)) }, ) } + +async fn client_response(ctx: &cli_engine::CommandContext, id: &str) -> Result { + let client = make_client(ctx).await?; + client.get_checkout(id).await.map_err(client_err) +} + +fn human_response(checkout: &Value) -> Value { + let line_items = checkout + .get("line_items") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .map(|item| { + json!({ + "quantity": item.get("quantity").and_then(Value::as_u64).unwrap_or(1), + "title": item.pointer("/item/title").and_then(Value::as_str).unwrap_or("Unknown item"), + "included": item.get("included_products").and_then(Value::as_array).map(|products| products.iter().filter_map(|product| product.get("title").and_then(Value::as_str)).collect::>()).unwrap_or_default(), + }) + }) + .collect::>() + }) + .unwrap_or_default(); + json!({ + "id": checkout.get("id").and_then(Value::as_str).unwrap_or_default(), + "status": checkout.get("status").and_then(Value::as_str).unwrap_or_default(), + "items": line_items, + "currency": checkout.get("currency").and_then(Value::as_str).unwrap_or_default(), + "totals": checkout.get("totals").cloned().unwrap_or_else(|| json!([])), + "selected_payment": selected_payment(checkout), + }) +} + +fn money(amount: i64, currency: Option<&str>) -> String { + format!("{} {:.2}", currency.unwrap_or(""), amount as f64 / 100.0) + .trim() + .to_owned() +} + +fn selected_payment(checkout: &Value) -> String { + checkout + .pointer("/payment/instruments") + .and_then(Value::as_array) + .and_then(|instruments| { + instruments.iter().find(|instrument| { + instrument.get("selected").and_then(Value::as_bool) == Some(true) + }) + }) + .and_then(|instrument| { + instrument + .get("rich_text_description") + .and_then(Value::as_str) + }) + .unwrap_or("No payment method selected") + .to_owned() +} + +fn render_human(checkout: &Value) -> String { + let mut output = format!( + "Checkout: {}\nStatus: {}\n", + checkout + .get("id") + .and_then(Value::as_str) + .unwrap_or_default(), + checkout + .get("status") + .and_then(Value::as_str) + .unwrap_or_default(), + ); + output.push_str("\nItems:\n"); + for item in checkout + .get("items") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + output.push_str(&format!( + "- {} × {}\n", + item.get("quantity").and_then(Value::as_u64).unwrap_or(1), + item.get("title") + .and_then(Value::as_str) + .unwrap_or("Unknown item"), + )); + for included in item + .get("included") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if let Some(included) = included.as_str() { + output.push_str(&format!(" Includes: {included}\n")); + } + } + } + output.push_str(&format!( + "\nSelected payment: {}\nTotals:\n", + checkout + .get("selected_payment") + .and_then(Value::as_str) + .unwrap_or("No payment method selected"), + )); + for total in checkout + .get("totals") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let label = total + .get("display_text") + .or_else(|| total.get("type")) + .and_then(Value::as_str) + .unwrap_or("Total"); + let amount = total + .get("amount") + .and_then(Value::as_i64) + .unwrap_or_default(); + output.push_str(&format!( + "- {label}: {}\n", + money(amount, checkout.get("currency").and_then(Value::as_str)), + )); + } + output.push_str("\nCompletion places a real order. Review this checkout before continuing.\n"); + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn human_view_masks_checkout_to_purchase_essentials() { + let output = render_human(&human_response(&json!({ + "id": "checkout-1", + "status": "ready_for_complete", + "line_items": [{ + "quantity": 1, + "item": {"title": "Web Hosting Economy"}, + "included_products": [{"title": "Standard SSL"}] + }], + "payment": {"instruments": [{ + "selected": true, + "rich_text_description": "CREDIT_CARD/VISA 1111", + "billing_address": {"street_address": "do not render"} + }]}, + "totals": [{"display_text": "Total", "amount": 8388}] + }))); + + assert!(output.contains("Web Hosting Economy")); + assert!(output.contains("CREDIT_CARD/VISA 1111")); + assert!(output.contains("Completion places a real order")); + assert!(!output.contains("do not render")); + } +} diff --git a/rust/src/shopping/checkout/mod.rs b/rust/src/shopping/checkout/mod.rs index ec55fc57..90c268af 100644 --- a/rust/src/shopping/checkout/mod.rs +++ b/rust/src/shopping/checkout/mod.rs @@ -1,6 +1,6 @@ mod complete; mod create; -mod get; +pub(super) mod get; mod update; use cli_engine::{GroupSpec, RuntimeGroupSpec}; diff --git a/rust/src/shopping/client.rs b/rust/src/shopping/client.rs index cac3c151..bdc4259f 100644 --- a/rust/src/shopping/client.rs +++ b/rust/src/shopping/client.rs @@ -181,6 +181,14 @@ mod tests { ShoppingClient::new(base_url, "test-token") } + #[test] + fn builds_shopping_paths_from_the_api_front_door() { + assert_eq!( + client("https://api.test-godaddy.com").url("/catalog/search"), + "https://api.test-godaddy.com/v1/shopping/catalog/search" + ); + } + #[tokio::test] async fn surfaces_order_not_found_as_retryable() { let server = MockServer::start_async().await; diff --git a/rust/src/shopping/common.rs b/rust/src/shopping/common.rs index a5105f8c..fb4280bf 100644 --- a/rust/src/shopping/common.rs +++ b/rust/src/shopping/common.rs @@ -13,15 +13,7 @@ pub(crate) async fn make_client(ctx: &CommandContext) -> Result .map(|scope| (*scope).to_owned()) .collect(); let token = ctx.credential_with_scopes(&required).await?.token; - let base_url = crate::environments::shopping_url(&ctx.middleware.env).ok_or_else(|| { - GddyError::config(format!( - "Shopping API URL is not configured for environment {:?}. Set shopping_url in \ - ~/.config/gddy/environments.toml, or set {}_SHOPPING_URL or SHOPPING_URL.", - ctx.middleware.env, - crate::environments::env_prefix(&ctx.middleware.env) - )) - .into_cli_error() - })?; + let base_url = crate::environments::resolve(&ctx.middleware.env)?.api_url; Ok(ShoppingClient::new(base_url, token)) } @@ -67,22 +59,51 @@ pub(crate) fn has_conflicting_checkout_id(body: &Value, id: &str) -> bool { .any(|body_id| body_id != id) } -pub(crate) fn require_idempotency_key(body: &Value) -> Result<()> { - if body - .get("idempotency_key") - .and_then(Value::as_str) - .is_some_and(|key| !key.trim().is_empty()) - { +pub(crate) fn require_selected_payment_instrument(body: &Value) -> Result<()> { + let selected = body + .pointer("/payment/instruments") + .and_then(Value::as_array) + .map(|instruments| { + instruments + .iter() + .filter(|instrument| { + instrument.get("selected").and_then(Value::as_bool) == Some(true) + }) + .count() + }); + if selected == Some(1) { Ok(()) } else { Err(GddyError::validation( - "checkout completion requires a non-empty idempotency_key in the JSON body", + "checkout completion requires exactly one selected payment instrument", + ) + .with_fix( + "Include payment.instruments with exactly one saved instrument marked selected: true.", ) - .with_fix("Reuse this idempotency_key if a completion request times out.") .into_cli_error()) } } +/// Returns a supplied non-empty key, or inserts a new UUID for this one request. +pub(crate) fn ensure_completion_idempotency_key(body: &mut Value) -> Result { + let object = body + .as_object_mut() + .expect("read_json validates the completion request is an object"); + match object.get("idempotency_key") { + None => { + let key = uuid::Uuid::new_v4().to_string(); + object.insert("idempotency_key".to_owned(), Value::String(key.clone())); + Ok(key) + } + Some(Value::String(key)) if !key.trim().is_empty() => Ok(key.clone()), + Some(_) => Err(GddyError::validation( + "idempotency_key must be a non-empty string when supplied", + ) + .with_fix("Supply a non-empty idempotency_key, or omit it to let gddy generate one.") + .into_cli_error()), + } +} + pub(crate) async fn wait_for_order( client: &ShoppingClient, order_id: &str, @@ -129,18 +150,82 @@ pub(crate) async fn wait_for_order( .into_cli_error()) } -pub(crate) fn wait_duration(raw: Option<&str>) -> Result { +pub(crate) fn wait_duration(seconds: Option) -> Result { const DEFAULT: Duration = Duration::from_secs(15); - let Some(raw) = raw else { - return Ok(DEFAULT); - }; - let seconds = raw.parse::().map_err(|_| { - GddyError::validation("--timeout must be a whole number of seconds").into_cli_error() - })?; - if seconds == 0 || seconds > 60 { - return Err( - GddyError::validation("--timeout must be between 1 and 60 seconds").into_cli_error(), + match seconds { + None => Ok(DEFAULT), + Some(seconds @ 1..=60) => Ok(Duration::from_secs(u64::from(seconds))), + Some(_) => Err( + GddyError::validation("--wait-timeout must be between 1 and 60 seconds") + .into_cli_error(), + ), + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn generates_and_inserts_missing_completion_idempotency_key() { + let mut request = json!({}); + let key = ensure_completion_idempotency_key(&mut request).expect("key should be generated"); + + assert!(uuid::Uuid::parse_str(&key).is_ok()); + assert_eq!(request["idempotency_key"], key); + } + + #[test] + fn preserves_supplied_completion_idempotency_key() { + let mut request = json!({"idempotency_key": "customer-key"}); + + assert_eq!( + ensure_completion_idempotency_key(&mut request).expect("key should be valid"), + "customer-key" + ); + } + + #[test] + fn rejects_blank_completion_idempotency_key() { + let mut request = json!({"idempotency_key": " "}); + + assert!(ensure_completion_idempotency_key(&mut request).is_err()); + } + + #[test] + fn requires_exactly_one_selected_payment_instrument() { + assert!( + require_selected_payment_instrument(&json!({ + "payment": {"instruments": [{"selected": true}]} + })) + .is_ok() + ); + assert!( + require_selected_payment_instrument(&json!({ + "payment": {"instruments": []} + })) + .is_err() + ); + assert!( + require_selected_payment_instrument(&json!({ + "payment": {"instruments": [{"selected": true}, {"selected": true}]} + })) + .is_err() + ); + } + + #[test] + fn validates_wait_timeout_range() { + assert_eq!( + wait_duration(None).expect("default"), + Duration::from_secs(15) + ); + assert_eq!( + wait_duration(Some(1)).expect("lower bound"), + Duration::from_secs(1) ); + assert!(wait_duration(Some(0)).is_err()); } - Ok(Duration::from_secs(seconds)) } diff --git a/rust/src/shopping/guides/shopping.md b/rust/src/shopping/guides/shopping.md index 35dca828..10e8e87e 100644 --- a/rust/src/shopping/guides/shopping.md +++ b/rust/src/shopping/guides/shopping.md @@ -22,8 +22,8 @@ gddy auth login \ --scope shopping.order:read ``` -PATs are not currently accepted by the configured Shopping API endpoint. Use OAuth -until Shopping is exposed through the front door, where PAT exchange can occur. +Shopping requests use the selected environment's standard API front door. Use OAuth +for Shopping commands. ## Workflow @@ -76,10 +76,11 @@ exposed by this CLI yet. ## Completing a checkout `checkout complete` places a real order. Its Shopping API request must include a selected -saved payment instrument and a caller-owned, non-empty `idempotency_key`. Use the checkout's +saved payment instrument. You can supply a non-empty `idempotency_key`, or omit it to let +gddy generate one and return it in the completion result. Use the checkout's `payment.instruments` list to select the saved instrument: mark exactly one entry with -`"selected": true`. Never create a new idempotency key when retrying an uncertain completion; -reuse the original key only for the same intended purchase. +`"selected": true`. For an uncertain completion, do not retry automatically; reuse the +effective idempotency key only for the same intended purchase after confirming its outcome. ```json { @@ -91,20 +92,25 @@ reuse the original key only for the same intended purchase. } ] }, - "idempotency_key": "" + "idempotency_key": "" } ``` +Omit `idempotency_key` to let gddy generate and return one. Include a stable key when you +need to control a later explicit retry. + ```bash gddy --env test shopping checkout complete \ - --file complete-checkout.json --wait-for-order + --file complete-checkout.json ``` -New orders normally become available 3–10 seconds after completion. `--wait-for-order` polls -the returned order ID for up to 15 seconds by default. You can also run: +Completion returns immediately after the purchase attempt, including the effective +`idempotency_key` and order ID when the Shopping API provides one. New orders normally become +available 3–10 seconds after completion. Retrieve the order separately, optionally polling for +up to 15 seconds by default: ```bash -gddy --env test shopping order get --wait --timeout 15 +gddy --env test shopping order get --wait --wait-timeout 15 ``` After completion, use `shopping order get` with the returned order ID. `shopping checkout get` diff --git a/rust/src/shopping/mod.rs b/rust/src/shopping/mod.rs index 20c1ea68..67d981e4 100644 --- a/rust/src/shopping/mod.rs +++ b/rust/src/shopping/mod.rs @@ -7,7 +7,8 @@ mod order; use cli_engine::{GroupSpec, Module, RuntimeGroupSpec}; -use crate::shopping::catalog::search::register_human_view; +use crate::shopping::catalog::search::register_human_view as register_catalog_search_human_view; +use crate::shopping::checkout::get::register_human_view as register_checkout_get_human_view; use crate::scopes::{SHOPPING_CATALOG_READ, SHOPPING_CHECKOUT_EXECUTE, SHOPPING_ORDER_READ}; @@ -22,7 +23,8 @@ pub(crate) const SHOPPING_SCOPES: &[&str] = &[ pub fn module() -> Module { Module::new("Shopping", |ctx| { - register_human_view(ctx); + register_catalog_search_human_view(ctx); + register_checkout_get_human_view(ctx); RuntimeGroupSpec::new( GroupSpec::new( "shopping", @@ -34,8 +36,7 @@ pub fn module() -> Module { Shopping commands request the required OAuth permissions together so you can \ complete the catalog → checkout → order workflow without additional consent prompts.\n\ \n\ - Use `gddy guide shopping` for request formats, checkout-completion safety, and \ - environment configuration.", + Use `gddy guide shopping` for request formats and checkout-completion safety.", ), ) .with_group(catalog::group()) diff --git a/rust/src/shopping/order/get.rs b/rust/src/shopping/order/get.rs index 84c672cc..440ed413 100644 --- a/rust/src/shopping/order/get.rs +++ b/rust/src/shopping/order/get.rs @@ -24,7 +24,7 @@ struct Args { /// Maximum seconds to wait for order visibility (1-60, default 15). #[arg(long, value_name = "SECONDS", requires = "wait")] - timeout: Option, + wait_timeout: Option, } pub(super) fn command() -> RuntimeCommandSpec { @@ -43,8 +43,7 @@ pub(super) fn command() -> RuntimeCommandSpec { let client = make_client(&ctx).await?; if args.wait { let (order, _) = - wait_for_order(&client, &args.id, wait_duration(args.timeout.as_deref())?) - .await?; + wait_for_order(&client, &args.id, wait_duration(args.wait_timeout)?).await?; Ok(CommandResult::new(order)) } else { Ok(CommandResult::new( From 95c0c5455a266676ffd672838ebb4dcdd84db01c Mon Sep 17 00:00:00 2001 From: sswaminathan Date: Wed, 9 Sep 2026 14:15:09 -0700 Subject: [PATCH 06/14] fix(shopping): harden purchase workflow Add catalog currency selection and currency-aware display, preserve full API data for JSON output, and streamline checkout creation through completion. Keep completion single-shot with generated idempotency keys and move order visibility polling to order retrieval. Co-Authored-By: Claude --- rust/src/shopping/catalog/get.rs | 14 ++- rust/src/shopping/catalog/lookup.rs | 14 ++- rust/src/shopping/catalog/search.rs | 75 +++++++++----- rust/src/shopping/checkout/complete.rs | 93 ++++++++++++++--- rust/src/shopping/checkout/create.rs | 32 ++++-- rust/src/shopping/checkout/get.rs | 81 ++++++++++----- rust/src/shopping/checkout/mod.rs | 2 +- rust/src/shopping/common.rs | 53 +++++++++- rust/src/shopping/guides/shopping.md | 136 +++++++++++++++++-------- rust/src/shopping/mod.rs | 11 ++ rust/src/shopping/money.rs | 58 +++++++++++ rust/src/shopping/order/get.rs | 3 +- 12 files changed, 443 insertions(+), 129 deletions(-) create mode 100644 rust/src/shopping/money.rs diff --git a/rust/src/shopping/catalog/get.rs b/rust/src/shopping/catalog/get.rs index fafd2cda..e2c4757e 100644 --- a/rust/src/shopping/catalog/get.rs +++ b/rust/src/shopping/catalog/get.rs @@ -2,7 +2,9 @@ use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; use crate::output_schema::output_schema; use crate::shopping::SHOPPING_SCOPES; -use crate::shopping::common::{client_err, make_client, read_json}; +use crate::shopping::common::{ + client_err, currency_code, make_client, merge_context_currency, read_json, +}; output_schema!(CatalogProductOutput { "ucp": "object"; @@ -18,6 +20,10 @@ struct Args { /// Path to a JSON product request. Takes precedence over --body. #[arg(long, value_name = "PATH")] file: Option, + + /// Preferred ISO 4217 currency for returned catalog prices (for example, USD or JPY). + #[arg(long, value_name = "CODE", value_parser = currency_code)] + currency: Option, } pub(super) fn command() -> RuntimeCommandSpec { @@ -27,10 +33,10 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("shopping") .with_tier(Tier::Read) .with_scopes(SHOPPING_SCOPES) - .with_output_schema::() - .with_default_fields("product"), + .with_output_schema::(), |ctx, args: Args| async move { - let body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + let mut body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + merge_context_currency(&mut body, args.currency.as_deref())?; let client = make_client(&ctx).await?; Ok(CommandResult::new( client.catalog_product(body).await.map_err(client_err)?, diff --git a/rust/src/shopping/catalog/lookup.rs b/rust/src/shopping/catalog/lookup.rs index edaf740a..8631b637 100644 --- a/rust/src/shopping/catalog/lookup.rs +++ b/rust/src/shopping/catalog/lookup.rs @@ -2,7 +2,9 @@ use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; use crate::output_schema::output_schema; use crate::shopping::SHOPPING_SCOPES; -use crate::shopping::common::{client_err, make_client, read_json}; +use crate::shopping::common::{ + client_err, currency_code, make_client, merge_context_currency, read_json, +}; output_schema!(CatalogLookupOutput { "ucp": "object"; @@ -19,6 +21,10 @@ struct Args { /// Path to a JSON lookup request. Takes precedence over --body. #[arg(long, value_name = "PATH")] file: Option, + + /// Preferred ISO 4217 currency for returned catalog prices (for example, USD or JPY). + #[arg(long, value_name = "CODE", value_parser = currency_code)] + currency: Option, } pub(super) fn command() -> RuntimeCommandSpec { @@ -31,10 +37,10 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("shopping") .with_tier(Tier::Read) .with_scopes(SHOPPING_SCOPES) - .with_output_schema::() - .with_default_fields("products,messages"), + .with_output_schema::(), |ctx, args: Args| async move { - let body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + let mut body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + merge_context_currency(&mut body, args.currency.as_deref())?; let client = make_client(&ctx).await?; Ok(CommandResult::new( client.catalog_lookup(body).await.map_err(client_err)?, diff --git a/rust/src/shopping/catalog/search.rs b/rust/src/shopping/catalog/search.rs index 6d1c216f..6f08343a 100644 --- a/rust/src/shopping/catalog/search.rs +++ b/rust/src/shopping/catalog/search.rs @@ -3,8 +3,11 @@ use serde_json::{Value, json}; use crate::next_action::{next_action, required_value}; use crate::output_schema::output_schema; -use crate::shopping::SHOPPING_SCOPES; -use crate::shopping::common::{client_err, make_client, read_json}; +use crate::shopping::common::{ + client_err, currency_code, make_client, merge_context_currency, read_json, +}; +use crate::shopping::money; +use crate::shopping::{SHOPPING_SCOPES, command_for_env}; output_schema!(CatalogSearchOutput { "products": "[]object"; @@ -22,6 +25,10 @@ struct Args { #[arg(long, value_name = "PATH")] file: Option, + /// Preferred ISO 4217 currency for returned catalog prices (for example, USD or JPY). + #[arg(long, value_name = "CODE", value_parser = currency_code)] + currency: Option, + /// Maximum number of products to return (1-100). #[arg(long, value_name = "N", value_parser = clap::value_parser!(u8).range(1..=100))] limit: Option, @@ -45,6 +52,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_view_id(HUMAN_VIEW_ID), |ctx, args: Args| async move { let mut request = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + merge_context_currency(&mut request, args.currency.as_deref())?; merge_pagination(&mut request, args.limit)?; let client = make_client(&ctx).await?; let response = client.catalog_search(request.clone()).await.map_err(client_err)?; @@ -147,12 +155,12 @@ fn next_actions( request: &mut Value, env: &str, ) -> Result> { - let mut actions = product_actions(response, env); + let mut actions = product_actions(response, request, env); actions.extend(next_page_action(response, request, env)?); Ok(actions) } -fn product_actions(response: &Value, env: &str) -> Vec { +fn product_actions(response: &Value, request: &Value, env: &str) -> Vec { let Some(product) = response .get("products") .and_then(Value::as_array) @@ -163,9 +171,13 @@ fn product_actions(response: &Value, env: &str) -> Vec { let Some(product_id) = product.get("id").and_then(Value::as_str) else { return Vec::new(); }; - let product_body = json!({"id": product_id}).to_string(); + let mut product_request = json!({"id": product_id}); + if let Some(currency) = request.pointer("/context/currency").and_then(Value::as_str) { + product_request["context"] = json!({"currency": currency}); + } + let product_body = product_request.to_string(); let mut actions = vec![next_action( - shopping_command(env, format!("catalog get --body '{product_body}'")), + command_for_env(env, format!("catalog get --body '{product_body}'")), "View the selected product's complete record", )]; if let Some(variant_id) = product @@ -193,7 +205,7 @@ fn product_actions(response: &Value, env: &str) -> Vec { .to_string(); actions.push( next_action( - shopping_command(env, format!("checkout create --body '{checkout_body}'")), + command_for_env(env, format!("checkout create --body '{checkout_body}'")), "Create a checkout with the first available variant", ) .with_param("variant_id", required_value(variant_id)), @@ -239,19 +251,11 @@ fn next_page_action( .into_cli_error() })?; Ok(vec![next_action( - shopping_command(env, format!("catalog search --body '{encoded_request}'")), + command_for_env(env, format!("catalog search --body '{encoded_request}'")), "Fetch the next catalog page", )]) } -fn shopping_command(env: &str, command: impl AsRef) -> String { - if matches!(env, "prod" | "production") { - format!("shopping {}", command.as_ref()) - } else { - format!("--env {env} shopping {}", command.as_ref()) - } -} - fn render_human(response: &Value) -> String { let mut output = response .get("summary") @@ -443,18 +447,16 @@ fn category(product: &Value) -> String { } fn money(value: Option<&Value>) -> Option { - let amount = value?.get("amount")?.as_i64()?; - let currency = value?.get("currency")?.as_str()?; - let major = amount / 100; - let minor = amount.unsigned_abs() % 100; - Some(format!("{currency} {major}.{minor:02}")) + money::format_value(value) } #[cfg(test)] mod tests { use serde_json::json; - use super::{human_response, merge_pagination, next_actions, render_human, shopping_command}; + use super::{human_response, merge_pagination, next_actions, render_human}; + use crate::shopping::command_for_env; + use crate::shopping::common::{currency_code, merge_context_currency}; fn response() -> serde_json::Value { json!({ @@ -532,12 +534,37 @@ mod tests { #[test] fn product_commands_preserve_non_production_environment() { assert_eq!( - shopping_command("prod", "catalog search"), + command_for_env("prod", "catalog search"), "shopping catalog search" ); assert_eq!( - shopping_command("test", "catalog search"), + command_for_env("test", "catalog search"), "--env test shopping catalog search" ); } + + #[test] + fn merges_currency_and_preserves_it_for_follow_up_actions() { + let response = response(); + let mut request = json!({"pagination": {"limit": 3}}); + merge_context_currency(&mut request, Some("jpy")).expect("valid currency"); + let actions = next_actions(&response, &mut request, "test").expect("actions"); + + assert_eq!(request.pointer("/context/currency"), Some(&json!("jpy"))); + assert!(actions[1].command.contains("\"currency\":\"USD\"")); + assert!(actions[2].command.contains("\"currency\":\"jpy\"")); + } + + #[test] + fn rejects_conflicting_currency_in_request_body() { + let mut request = json!({"context": {"currency": "USD"}}); + assert!(merge_context_currency(&mut request, Some("JPY")).is_err()); + } + + #[test] + fn validates_and_normalizes_currency_codes() { + assert_eq!(currency_code(" jpy ").expect("valid currency"), "JPY"); + assert!(currency_code("JP").is_err()); + assert!(currency_code("123").is_err()); + } } diff --git a/rust/src/shopping/checkout/complete.rs b/rust/src/shopping/checkout/complete.rs index 6e485cee..17674fb4 100644 --- a/rust/src/shopping/checkout/complete.rs +++ b/rust/src/shopping/checkout/complete.rs @@ -1,13 +1,13 @@ -use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; -use serde_json::json; +use cli_engine::{CommandResult, CommandSpec, ModuleContext, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; -use crate::next_action::next_action; -use crate::shopping::SHOPPING_SCOPES; +use crate::next_action::{next_action, required_value}; use crate::shopping::client::ClientError; use crate::shopping::common::{ ensure_completion_idempotency_key, has_conflicting_checkout_id, make_client, read_json, require_selected_payment_instrument, }; +use crate::shopping::{SHOPPING_SCOPES, command_for_env}; #[derive(Debug, Clone, clap::Args)] struct Args { @@ -24,6 +24,62 @@ struct Args { file: Option, } +const HUMAN_VIEW_ID: &str = "shopping-checkout-complete"; + +pub(crate) fn register_human_view(ctx: &mut ModuleContext<'_>) { + ctx.middleware_mut() + .human_views + .register_func(HUMAN_VIEW_ID, render_human); +} + +fn human_response(completion: &Value, idempotency_key: &str) -> Value { + json!({ + "checkout_id": completion.get("id").and_then(Value::as_str).unwrap_or_default(), + "status": completion.get("status").and_then(Value::as_str).unwrap_or_default(), + "order_id": completion.pointer("/order/id").and_then(Value::as_str), + "idempotency_key": idempotency_key, + }) +} + +fn render_human(completion: &Value) -> String { + if let Some(action) = completion.get("action").and_then(Value::as_str) { + let idempotency_key = completion + .get("idempotency_key") + .and_then(Value::as_str) + .unwrap_or_default(); + let body = completion.get("body").cloned().unwrap_or(Value::Null); + return format!( + "{}\nCheckout: {}\nIdempotency key: {idempotency_key}\nRequest:\n{}\n", + action, + completion + .get("id") + .and_then(Value::as_str) + .unwrap_or_default(), + serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string()), + ); + } + let mut output = format!( + "Checkout: {}\nStatus: {}\nIdempotency key: {}\n", + completion + .get("checkout_id") + .and_then(Value::as_str) + .unwrap_or_default(), + completion + .get("status") + .and_then(Value::as_str) + .unwrap_or_default(), + completion + .get("idempotency_key") + .and_then(Value::as_str) + .unwrap_or_default(), + ); + if let Some(order_id) = completion.get("order_id").and_then(Value::as_str) { + output.push_str(&format!("Order: {order_id}\n")); + } + output.push_str("\nKeep this idempotency key. Do not retry a completion unless you first confirm its outcome.\n"); + output +} + fn completion_error(error: ClientError, idempotency_key: &str) -> cli_engine::CliCoreError { crate::error::GddyError::from(error) .with_fix(format!( @@ -48,7 +104,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .mutates(true) .handles_dry_run(true) .with_scopes(SHOPPING_SCOPES) - .auth_optional(), + .auth_optional() + .with_view_id(HUMAN_VIEW_ID), |ctx, args: Args| async move { let mut body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; if has_conflicting_checkout_id(&body, &args.id) { @@ -74,17 +131,25 @@ pub(super) fn command() -> RuntimeCommandSpec { })?; let order_id = completion .pointer("/order/id") - .and_then(serde_json::Value::as_str) + .and_then(Value::as_str) .map(str::to_owned); - let mut result = CommandResult::new(json!({ - "idempotency_key": idempotency_key, - "completion": completion, - })); + let mut result = CommandResult::new(if ctx.middleware.output_format == "human" { + human_response(&completion, &idempotency_key) + } else { + completion + }); if let Some(order_id) = order_id { - result = result.with_next_actions(vec![next_action( - format!("shopping order get {order_id} --wait"), - "Read the completed order after it becomes visible", - )]); + result = result.with_next_actions(vec![ + next_action( + command_for_env( + &ctx.middleware.env, + format!("order get {order_id} --wait"), + ), + "Read the completed order after it becomes visible", + ) + .with_param("order_id", required_value(order_id)) + .with_param("idempotency_key", required_value(idempotency_key)), + ]); } Ok(result) }, diff --git a/rust/src/shopping/checkout/create.rs b/rust/src/shopping/checkout/create.rs index d1fd5e0e..1c2ddace 100644 --- a/rust/src/shopping/checkout/create.rs +++ b/rust/src/shopping/checkout/create.rs @@ -1,8 +1,10 @@ use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use serde_json::Value; +use crate::next_action::{next_action, required_value}; use crate::output_schema::output_schema; -use crate::shopping::SHOPPING_SCOPES; use crate::shopping::common::{client_err, make_client, read_json}; +use crate::shopping::{SHOPPING_SCOPES, command_for_env}; output_schema!(CheckoutOutput { "ucp": "object"; @@ -42,8 +44,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .handles_dry_run(true) .with_scopes(SHOPPING_SCOPES) .auth_optional() - .with_output_schema::() - .with_default_fields("id,status,line_items,totals,messages,action,body"), + .with_output_schema::(), |ctx, args: Args| async move { let body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; if ctx.dry_run() { @@ -53,9 +54,28 @@ pub(super) fn command() -> RuntimeCommandSpec { }))); } let client = make_client(&ctx).await?; - Ok(CommandResult::new( - client.create_checkout(body).await.map_err(client_err)?, - )) + let checkout = client.create_checkout(body).await.map_err(client_err)?; + let ready_for_complete = + checkout.get("status").and_then(Value::as_str) == Some("ready_for_complete"); + let checkout_id = checkout + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let mut result = CommandResult::new(checkout); + if ready_for_complete { + result = result.with_next_actions(vec![ + next_action( + command_for_env( + &ctx.middleware.env, + format!("checkout complete {checkout_id} --file complete-checkout.json"), + ), + "Complete this checkout with a selected saved payment instrument", + ) + .with_param("checkout_id", required_value(checkout_id)), + ]); + } + Ok(result) }, ) } diff --git a/rust/src/shopping/checkout/get.rs b/rust/src/shopping/checkout/get.rs index 5ef45920..6795a034 100644 --- a/rust/src/shopping/checkout/get.rs +++ b/rust/src/shopping/checkout/get.rs @@ -1,10 +1,12 @@ use cli_engine::{CommandResult, CommandSpec, ModuleContext, Result, RuntimeCommandSpec, Tier}; use serde_json::{Value, json}; -use crate::shopping::SHOPPING_SCOPES; +use crate::next_action::next_action; use crate::shopping::common::{client_err, make_client}; +use crate::shopping::money; +use crate::shopping::{SHOPPING_SCOPES, command_for_env}; -const HUMAN_VIEW_ID: &str = "shopping-checkout-get"; +pub(super) const HUMAN_VIEW_ID: &str = "shopping-checkout-get"; pub(crate) fn register_human_view(ctx: &mut ModuleContext<'_>) { ctx.middleware_mut() @@ -33,12 +35,27 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_view_id(HUMAN_VIEW_ID), |ctx, args: Args| async move { let checkout = client_response(&ctx, &args.id).await?; + let ready_for_complete = + checkout.get("status").and_then(Value::as_str) == Some("ready_for_complete"); let output = if ctx.middleware.output_format == "human" { human_response(&checkout) } else { checkout }; - Ok(CommandResult::new(output)) + let mut result = CommandResult::new(output); + if ready_for_complete { + result = result.with_next_actions(vec![next_action( + command_for_env( + &ctx.middleware.env, + format!( + "checkout complete {} --file complete-checkout.json", + args.id + ), + ), + "Complete this checkout after reviewing its selected payment method", + )]); + } + Ok(result) }, ) } @@ -48,7 +65,7 @@ async fn client_response(ctx: &cli_engine::CommandContext, id: &str) -> Result Value { +pub(super) fn human_response(checkout: &Value) -> Value { let line_items = checkout .get("line_items") .and_then(Value::as_array) @@ -75,14 +92,8 @@ fn human_response(checkout: &Value) -> Value { }) } -fn money(amount: i64, currency: Option<&str>) -> String { - format!("{} {:.2}", currency.unwrap_or(""), amount as f64 / 100.0) - .trim() - .to_owned() -} - fn selected_payment(checkout: &Value) -> String { - checkout + let Some(instrument) = checkout .pointer("/payment/instruments") .and_then(Value::as_array) .and_then(|instruments| { @@ -90,13 +101,17 @@ fn selected_payment(checkout: &Value) -> String { instrument.get("selected").and_then(Value::as_bool) == Some(true) }) }) - .and_then(|instrument| { - instrument - .get("rich_text_description") - .and_then(Value::as_str) - }) - .unwrap_or("No payment method selected") - .to_owned() + else { + return "No payment method selected".to_owned(); + }; + let description = instrument + .get("rich_text_description") + .and_then(Value::as_str) + .unwrap_or("Selected payment method"); + match instrument.get("id").and_then(Value::as_str) { + Some(id) => format!("{description} (ID: {id})"), + None => description.to_owned(), + } } fn render_human(checkout: &Value) -> String { @@ -112,12 +127,15 @@ fn render_human(checkout: &Value) -> String { .unwrap_or_default(), ); output.push_str("\nItems:\n"); - for item in checkout + let items = checkout .get("items") .and_then(Value::as_array) - .into_iter() - .flatten() - { + .map(Vec::as_slice) + .unwrap_or_default(); + if items.is_empty() { + output.push_str("- None\n"); + } + for item in items { output.push_str(&format!( "- {} × {}\n", item.get("quantity").and_then(Value::as_u64).unwrap_or(1), @@ -143,12 +161,15 @@ fn render_human(checkout: &Value) -> String { .and_then(Value::as_str) .unwrap_or("No payment method selected"), )); - for total in checkout + let totals = checkout .get("totals") .and_then(Value::as_array) - .into_iter() - .flatten() - { + .map(Vec::as_slice) + .unwrap_or_default(); + if totals.is_empty() { + output.push_str("- None\n"); + } + for total in totals { let label = total .get("display_text") .or_else(|| total.get("type")) @@ -160,7 +181,13 @@ fn render_human(checkout: &Value) -> String { .unwrap_or_default(); output.push_str(&format!( "- {label}: {}\n", - money(amount, checkout.get("currency").and_then(Value::as_str)), + checkout + .get("currency") + .and_then(Value::as_str) + .map_or_else( + || amount.to_string(), + |currency| money::format_amount(amount, currency) + ), )); } output.push_str("\nCompletion places a real order. Review this checkout before continuing.\n"); diff --git a/rust/src/shopping/checkout/mod.rs b/rust/src/shopping/checkout/mod.rs index 90c268af..662cd595 100644 --- a/rust/src/shopping/checkout/mod.rs +++ b/rust/src/shopping/checkout/mod.rs @@ -1,4 +1,4 @@ -mod complete; +pub(super) mod complete; mod create; pub(super) mod get; mod update; diff --git a/rust/src/shopping/common.rs b/rust/src/shopping/common.rs index fb4280bf..17e3be1f 100644 --- a/rust/src/shopping/common.rs +++ b/rust/src/shopping/common.rs @@ -59,6 +59,44 @@ pub(crate) fn has_conflicting_checkout_id(body: &Value, id: &str) -> bool { .any(|body_id| body_id != id) } +pub(crate) fn currency_code(value: &str) -> std::result::Result { + let normalized = value.trim().to_ascii_uppercase(); + if normalized.len() == 3 + && normalized + .chars() + .all(|character| character.is_ascii_alphabetic()) + { + Ok(normalized) + } else { + Err("currency must be a three-letter ISO 4217 code".to_owned()) + } +} + +pub(crate) fn merge_context_currency(request: &mut Value, currency: Option<&str>) -> Result<()> { + let Some(currency) = currency else { + return Ok(()); + }; + let object = request + .as_object_mut() + .expect("read_json validates the request is an object"); + let context = object + .entry("context") + .or_insert_with(|| serde_json::json!({})); + let context = context + .as_object_mut() + .ok_or_else(|| GddyError::validation("context must be a JSON object").into_cli_error())?; + if let Some(existing) = context.get("currency").and_then(Value::as_str) + && !existing.eq_ignore_ascii_case(currency) + { + return Err(GddyError::validation( + "--currency conflicts with context.currency in the request body", + ) + .into_cli_error()); + } + context.insert("currency".to_owned(), Value::String(currency.to_owned())); + Ok(()) +} + pub(crate) fn require_selected_payment_instrument(body: &Value) -> Result<()> { let selected = body .pointer("/payment/instruments") @@ -69,16 +107,21 @@ pub(crate) fn require_selected_payment_instrument(body: &Value) -> Result<()> { .filter(|instrument| { instrument.get("selected").and_then(Value::as_bool) == Some(true) }) - .count() + .collect::>() }); - if selected == Some(1) { + if let Some([instrument]) = selected.as_deref() + && instrument + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| !id.trim().is_empty()) + { Ok(()) } else { Err(GddyError::validation( - "checkout completion requires exactly one selected payment instrument", + "checkout completion requires exactly one selected saved payment instrument with an ID", ) .with_fix( - "Include payment.instruments with exactly one saved instrument marked selected: true.", + "Include payment.instruments with exactly one saved instrument ID marked selected: true.", ) .into_cli_error()) } @@ -198,7 +241,7 @@ mod tests { fn requires_exactly_one_selected_payment_instrument() { assert!( require_selected_payment_instrument(&json!({ - "payment": {"instruments": [{"selected": true}]} + "payment": {"instruments": [{"id": "payment-1", "selected": true}]} })) .is_ok() ); diff --git a/rust/src/shopping/guides/shopping.md b/rust/src/shopping/guides/shopping.md index 10e8e87e..92cc065e 100644 --- a/rust/src/shopping/guides/shopping.md +++ b/rust/src/shopping/guides/shopping.md @@ -25,62 +25,108 @@ gddy auth login \ Shopping requests use the selected environment's standard API front door. Use OAuth for Shopping commands. -## Workflow +## Request documents and output Use `--body` for a small inline JSON request or `--file` for a reusable JSON document. -`--file` takes precedence over `--body`. The Shopping API uses nested checkout objects, so -checkout create, update, and complete requests remain JSON documents instead of a long list -of CLI flags. Use `gddy shopping --help` for command-specific requirements; the -examples below show the request fields required for common checkout operations. +`--file` takes precedence over `--body`. The Shopping API uses nested checkout objects, +so checkout create, update, and complete requests remain JSON documents instead of a +long list of CLI flags. + +For successful non-dry-run requests, `--output json` keeps the full Shopping API +response in the envelope's `data` field. Human output is a concise presentation of +that response. Use `gddy shopping --help` for command-specific requirements. + +## Discover products + +Search the catalog with an optional ISO 4217 presentment-currency preference: ```bash -gddy --env test shopping catalog search --body '{}' --limit 3 -gddy --env test shopping catalog lookup --body '{"ids":["nes-wsb-vnext-tier1"]}' -gddy --env test shopping catalog get --body '{"id":"nes-wsb-vnext-tier1"}' -gddy --env test shopping checkout create --body '{"context":{"currency":"USD"},"line_items":[{"item":{"id":"nes-wsb-vnext-tier1"},"quantity":1}]}' -gddy --env test shopping checkout update --file update-checkout.json +gddy --env test shopping catalog search --currency JPY --limit 3 --body '{}' ``` -Use a variant ID selected from `catalog search` as `line_items[].item.id`; product IDs are -for catalog lookup. For a full checkout update, start with the open checkout returned by -`shopping checkout get `, edit the complete desired state, and send it with -`--file`. `update` replaces the checkout with the supplied document, so omitted fields may -be removed. An empty `line_items` array deliberately clears the cart. - -### Catalog search and pagination +`--currency` writes `context.currency` into the request. You can instead place it in +the JSON document. Supplying both with different values fails. The API response price +currency is authoritative; a requested currency is a preference, not a guarantee. The +current Shopping service applies the preference to catalog search; catalog lookup and +product retrieval currently accept the context but can return their default USD prices. `catalog search` displays products as numbered sections. Each section shows its product -ID, then the purchasable variants and their prices. Use the **product ID** with -`catalog get` or `catalog lookup`; use a **variant ID** in `checkout create` line items. +ID, then purchasable variants and prices. `--limit` controls **products**, not variants. +Use a **product ID** with `catalog get` or `catalog lookup`; use a **variant ID** in +`checkout create` line items. + +```bash +gddy --env test shopping catalog lookup \ + --body '{"ids":["nes-wsb-vnext-tier1"]}' --currency JPY -`--limit` controls the number of returned **products**, not variants. A returned product -can contain multiple purchasable variants. To receive the original Shopping API response as -valid JSON—including product metadata, variants, messages, and pagination—use `--output json`: +gddy --env test shopping catalog get \ + --body '{"id":"nes-wsb-vnext-tier1"}' --currency JPY +``` + +To receive the full catalog response as valid JSON: ```bash gddy --env test --output json shopping catalog search --body '{}' --limit 3 ``` -Shopping API cursor pagination belongs in the request body's `pagination` object. Preserve all -original search criteria, retain the original `pagination.limit`, and replace only the -opaque `pagination.cursor` with the cursor from the preceding response: +Shopping API cursor pagination belongs in the request body's `pagination` object. +Preserve all original search criteria—including `context.currency`—retain the original +`pagination.limit`, and replace only the opaque `pagination.cursor`: ```bash gddy --env test shopping catalog search \ - --body '{"pagination":{"limit":3,"cursor":""}}' + --body '{"context":{"currency":"JPY"},"pagination":{"limit":3,"cursor":""}}' +``` + +## Create a checkout ready to complete + +Creating a checkout does not place an order. A create response includes the checkout ID, +priced line items, totals, and available payment instruments, so a separate `checkout get` +is not required before completion when the checkout is already ready. Include buyer, payment, +and other supported checkout information when creating a checkout that is ready to complete. + +```bash +gddy --env test shopping checkout create --body '{ + "context": {"currency": "USD"}, + "line_items": [ + { + "item": {"id": ""}, + "quantity": 1 + } + ], + "buyer": { + "first_name": "Jane", + "last_name": "Doe", + "email": "jane.doe@example.test", + "phone_number": "+15550100" + }, + "payment": { + "instruments": [ + {"id": "", "selected": true} + ] + } +}' ``` -Checkout updates use full-replacement `PUT` requests. PATCH is intentionally not -exposed by this CLI yet. +Use `checkout get ` when you need to inspect an existing open checkout or +recover its available payment instruments. Its human output shows checkout status, items, +totals, and the selected masked payment method. Use `--output json` for the full response. + +## Optionally update an open checkout -## Completing a checkout +`checkout update` is optional. Use it only to change an existing checkout. It performs a +full-replacement `PUT`: include every line item and all retained fields in the document. +An empty `line_items` array deliberately clears the cart. PATCH is not exposed by this CLI. + +```bash +gddy --env test shopping checkout update --file update-checkout.json +``` -`checkout complete` places a real order. Its Shopping API request must include a selected -saved payment instrument. You can supply a non-empty `idempotency_key`, or omit it to let -gddy generate one and return it in the completion result. Use the checkout's -`payment.instruments` list to select the saved instrument: mark exactly one entry with -`"selected": true`. For an uncertain completion, do not retry automatically; reuse the -effective idempotency key only for the same intended purchase after confirming its outcome. +## Complete a checkout + +`checkout complete` places a real order. Provide exactly one selected saved payment +instrument. You can supply a non-empty `idempotency_key`, or omit it to let gddy generate +one and return it in human output. Preserve the effective key for lost-response recovery. ```json { @@ -96,22 +142,28 @@ effective idempotency key only for the same intended purchase after confirming i } ``` -Omit `idempotency_key` to let gddy generate and return one. Include a stable key when you -need to control a later explicit retry. - ```bash gddy --env test shopping checkout complete \ --file complete-checkout.json ``` -Completion returns immediately after the purchase attempt, including the effective -`idempotency_key` and order ID when the Shopping API provides one. New orders normally become -available 3–10 seconds after completion. Retrieve the order separately, optionally polling for -up to 15 seconds by default: +Completion returns immediately after the single purchase attempt. It never automatically +retries. If the result is uncertain, first confirm the outcome; only then reuse the same +effective idempotency key for the same intended purchase. + +New orders normally become available 3–10 seconds after completion. Retrieve the order +separately, optionally polling for up to 15 seconds by default: ```bash gddy --env test shopping order get --wait --wait-timeout 15 ``` +The engine-wide `--timeout` remains independent of order visibility waiting: + +```bash +gddy --env test --timeout 30s shopping order get \ + --wait --wait-timeout 15 +``` + After completion, use `shopping order get` with the returned order ID. `shopping checkout get` is for open checkout sessions only. diff --git a/rust/src/shopping/mod.rs b/rust/src/shopping/mod.rs index 67d981e4..8e2e5f7c 100644 --- a/rust/src/shopping/mod.rs +++ b/rust/src/shopping/mod.rs @@ -3,11 +3,13 @@ pub mod client; mod catalog; mod checkout; mod common; +mod money; mod order; use cli_engine::{GroupSpec, Module, RuntimeGroupSpec}; use crate::shopping::catalog::search::register_human_view as register_catalog_search_human_view; +use crate::shopping::checkout::complete::register_human_view as register_checkout_complete_human_view; use crate::shopping::checkout::get::register_human_view as register_checkout_get_human_view; use crate::scopes::{SHOPPING_CATALOG_READ, SHOPPING_CHECKOUT_EXECUTE, SHOPPING_ORDER_READ}; @@ -21,9 +23,18 @@ pub(crate) const SHOPPING_SCOPES: &[&str] = &[ SHOPPING_ORDER_READ, ]; +pub(crate) fn command_for_env(env: &str, command: impl AsRef) -> String { + if matches!(env, "prod" | "production") { + format!("shopping {}", command.as_ref()) + } else { + format!("--env {env} shopping {}", command.as_ref()) + } +} + pub fn module() -> Module { Module::new("Shopping", |ctx| { register_catalog_search_human_view(ctx); + register_checkout_complete_human_view(ctx); register_checkout_get_human_view(ctx); RuntimeGroupSpec::new( GroupSpec::new( diff --git a/rust/src/shopping/money.rs b/rust/src/shopping/money.rs new file mode 100644 index 00000000..5a51e078 --- /dev/null +++ b/rust/src/shopping/money.rs @@ -0,0 +1,58 @@ +use serde_json::Value; + +/// Shopping currently returns integer price amounts in hundredths for every observed currency. +/// The service does not yet apply ISO 4217 currency-specific minor-unit exponents. +pub(crate) fn format_value(value: Option<&Value>) -> Option { + let amount = value?.get("amount")?.as_i64()?; + let currency = value?.get("currency")?.as_str()?; + Some(format_amount(amount, currency)) +} + +pub(crate) fn format_amount(amount: i64, currency: &str) -> String { + let sign = if amount < 0 { "-" } else { "" }; + let absolute = amount.unsigned_abs(); + let whole = absolute / 100; + let fractional = absolute % 100; + let whole = grouped_integer(whole); + if fractional == 0 && uses_zero_decimal_display(currency) { + format!("{currency} {sign}{whole}") + } else { + format!("{currency} {sign}{whole}.{fractional:02}") + } +} + +fn grouped_integer(value: u64) -> String { + let digits = value.to_string(); + let first_group = digits.len() % 3; + let mut output = String::with_capacity(digits.len() + digits.len() / 3); + if first_group > 0 { + output.push_str(&digits[..first_group]); + } + for index in (first_group..digits.len()).step_by(3) { + if !output.is_empty() { + output.push(','); + } + output.push_str(&digits[index..index + 3]); + } + output +} + +fn uses_zero_decimal_display(currency: &str) -> bool { + matches!(currency, "JPY") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_observed_usd_and_jpy_amounts() { + assert_eq!(format_amount(7188, "USD"), "USD 71.88"); + assert_eq!(format_amount(1_198_800, "JPY"), "JPY 11,988"); + } + + #[test] + fn retains_fractional_amounts_for_zero_decimal_display_currencies() { + assert_eq!(format_amount(1_198_801, "JPY"), "JPY 11,988.01"); + } +} diff --git a/rust/src/shopping/order/get.rs b/rust/src/shopping/order/get.rs index 440ed413..5e96e842 100644 --- a/rust/src/shopping/order/get.rs +++ b/rust/src/shopping/order/get.rs @@ -37,8 +37,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("shopping") .with_tier(Tier::Read) .with_scopes(SHOPPING_SCOPES) - .with_output_schema::() - .with_default_fields("id,checkout_id,line_items,totals"), + .with_output_schema::(), |ctx, args: Args| async move { let client = make_client(&ctx).await?; if args.wait { From ff29a93574e7af6fb2c91aaa323418bcce6960d2 Mon Sep 17 00:00:00 2001 From: sswaminathan Date: Wed, 9 Sep 2026 17:02:41 -0700 Subject: [PATCH 07/14] fix(shopping): improve guide and completion response Co-Authored-By: Claude --- rust/src/shopping/catalog/get.rs | 2 +- rust/src/shopping/catalog/lookup.rs | 2 +- rust/src/shopping/catalog/search.rs | 2 +- rust/src/shopping/checkout/complete.rs | 4 ++ rust/src/shopping/guides/shopping.md | 76 ++++++++++++++++++-------- 5 files changed, 61 insertions(+), 25 deletions(-) diff --git a/rust/src/shopping/catalog/get.rs b/rust/src/shopping/catalog/get.rs index e2c4757e..39cc12cb 100644 --- a/rust/src/shopping/catalog/get.rs +++ b/rust/src/shopping/catalog/get.rs @@ -21,7 +21,7 @@ struct Args { #[arg(long, value_name = "PATH")] file: Option, - /// Preferred ISO 4217 currency for returned catalog prices (for example, USD or JPY). + /// Preferred ISO 4217 currency for returned catalog prices (for example, USD or GBP). #[arg(long, value_name = "CODE", value_parser = currency_code)] currency: Option, } diff --git a/rust/src/shopping/catalog/lookup.rs b/rust/src/shopping/catalog/lookup.rs index 8631b637..8f284cc3 100644 --- a/rust/src/shopping/catalog/lookup.rs +++ b/rust/src/shopping/catalog/lookup.rs @@ -22,7 +22,7 @@ struct Args { #[arg(long, value_name = "PATH")] file: Option, - /// Preferred ISO 4217 currency for returned catalog prices (for example, USD or JPY). + /// Preferred ISO 4217 currency for returned catalog prices (for example, USD or GBP). #[arg(long, value_name = "CODE", value_parser = currency_code)] currency: Option, } diff --git a/rust/src/shopping/catalog/search.rs b/rust/src/shopping/catalog/search.rs index 6f08343a..6c4c5e29 100644 --- a/rust/src/shopping/catalog/search.rs +++ b/rust/src/shopping/catalog/search.rs @@ -25,7 +25,7 @@ struct Args { #[arg(long, value_name = "PATH")] file: Option, - /// Preferred ISO 4217 currency for returned catalog prices (for example, USD or JPY). + /// Preferred ISO 4217 currency for returned catalog prices (for example, USD or GBP). #[arg(long, value_name = "CODE", value_parser = currency_code)] currency: Option, diff --git a/rust/src/shopping/checkout/complete.rs b/rust/src/shopping/checkout/complete.rs index 17674fb4..01d13437 100644 --- a/rust/src/shopping/checkout/complete.rs +++ b/rust/src/shopping/checkout/complete.rs @@ -37,6 +37,7 @@ fn human_response(completion: &Value, idempotency_key: &str) -> Value { "checkout_id": completion.get("id").and_then(Value::as_str).unwrap_or_default(), "status": completion.get("status").and_then(Value::as_str).unwrap_or_default(), "order_id": completion.pointer("/order/id").and_then(Value::as_str), + "order_permalink": completion.pointer("/order/permalink_url").and_then(Value::as_str), "idempotency_key": idempotency_key, }) } @@ -76,6 +77,9 @@ fn render_human(completion: &Value) -> String { if let Some(order_id) = completion.get("order_id").and_then(Value::as_str) { output.push_str(&format!("Order: {order_id}\n")); } + if let Some(permalink) = completion.get("order_permalink").and_then(Value::as_str) { + output.push_str(&format!("View order: {permalink}\n")); + } output.push_str("\nKeep this idempotency key. Do not retry a completion unless you first confirm its outcome.\n"); output } diff --git a/rust/src/shopping/guides/shopping.md b/rust/src/shopping/guides/shopping.md index 92cc065e..738c1cdd 100644 --- a/rust/src/shopping/guides/shopping.md +++ b/rust/src/shopping/guides/shopping.md @@ -25,6 +25,15 @@ gddy auth login \ Shopping requests use the selected environment's standard API front door. Use OAuth for Shopping commands. +## Environment + +Commands below use the active environment. To run them against another configured +environment, add `--env ` before `shopping`: + +```bash +gddy --env shopping catalog search --body '{}' +``` + ## Request documents and output Use `--body` for a small inline JSON request or `--file` for a reusable JSON document. @@ -32,16 +41,17 @@ Use `--body` for a small inline JSON request or `--file` for a reusable JSON doc so checkout create, update, and complete requests remain JSON documents instead of a long list of CLI flags. -For successful non-dry-run requests, `--output json` keeps the full Shopping API -response in the envelope's `data` field. Human output is a concise presentation of -that response. Use `gddy shopping --help` for command-specific requirements. +JSON is the default output format. For successful non-dry-run requests, the full +Shopping API response is in the envelope's `data` field. Add `--output human` for a +concise terminal presentation, or `--output json` to make the default explicit. Use +`gddy shopping --help` for command-specific requirements. ## Discover products Search the catalog with an optional ISO 4217 presentment-currency preference: ```bash -gddy --env test shopping catalog search --currency JPY --limit 3 --body '{}' +gddy shopping catalog search --currency GBP --limit 3 --body '{}' ``` `--currency` writes `context.currency` into the request. You can instead place it in @@ -56,17 +66,17 @@ Use a **product ID** with `catalog get` or `catalog lookup`; use a **variant ID* `checkout create` line items. ```bash -gddy --env test shopping catalog lookup \ - --body '{"ids":["nes-wsb-vnext-tier1"]}' --currency JPY +gddy shopping catalog lookup \ + --body '{"ids":["nes-wsb-vnext-tier1"]}' --currency GBP -gddy --env test shopping catalog get \ - --body '{"id":"nes-wsb-vnext-tier1"}' --currency JPY +gddy shopping catalog get \ + --body '{"id":"nes-wsb-vnext-tier1"}' --currency GBP ``` To receive the full catalog response as valid JSON: ```bash -gddy --env test --output json shopping catalog search --body '{}' --limit 3 +gddy --output json shopping catalog search --body '{}' --limit 3 ``` Shopping API cursor pagination belongs in the request body's `pagination` object. @@ -74,8 +84,8 @@ Preserve all original search criteria—including `context.currency`—retain th `pagination.limit`, and replace only the opaque `pagination.cursor`: ```bash -gddy --env test shopping catalog search \ - --body '{"context":{"currency":"JPY"},"pagination":{"limit":3,"cursor":""}}' +gddy shopping catalog search \ + --body '{"context":{"currency":"GBP"},"pagination":{"limit":3,"cursor":""}}' ``` ## Create a checkout ready to complete @@ -86,7 +96,7 @@ is not required before completion when the checkout is already ready. Include bu and other supported checkout information when creating a checkout that is ready to complete. ```bash -gddy --env test shopping checkout create --body '{ +gddy shopping checkout create --body '{ "context": {"currency": "USD"}, "line_items": [ { @@ -102,24 +112,38 @@ gddy --env test shopping checkout create --body '{ }, "payment": { "instruments": [ - {"id": "", "selected": true} + { + "id": "", + "selected": true, + "billing_address": { + "street_address": "123 Example Street", + "address_locality": "Exampleville", + "address_region": "CA", + "postal_code": "94043", + "address_country": "US" + } + } ] } }' ``` +`billing_address` on the selected saved payment instrument is optional. You can provide it +when creating the checkout or in the completion request; omitting it retains the saved +instrument's existing billing address. + Use `checkout get ` when you need to inspect an existing open checkout or recover its available payment instruments. Its human output shows checkout status, items, totals, and the selected masked payment method. Use `--output json` for the full response. ## Optionally update an open checkout -`checkout update` is optional. Use it only to change an existing checkout. It performs a -full-replacement `PUT`: include every line item and all retained fields in the document. -An empty `line_items` array deliberately clears the cart. PATCH is not exposed by this CLI. +`checkout update` is optional. Use it only to change an existing checkout. It replaces the +checkout with the supplied document, so include every line item and all retained fields. An empty +`line_items` array deliberately clears the cart. ```bash -gddy --env test shopping checkout update --file update-checkout.json +gddy shopping checkout update --file update-checkout.json ``` ## Complete a checkout @@ -134,7 +158,14 @@ one and return it in human output. Preserve the effective key for lost-response "instruments": [ { "id": "", - "selected": true + "selected": true, + "billing_address": { + "street_address": "123 Example Street", + "address_locality": "Exampleville", + "address_region": "CA", + "postal_code": "94043", + "address_country": "US" + } } ] }, @@ -143,11 +174,12 @@ one and return it in human output. Preserve the effective key for lost-response ``` ```bash -gddy --env test shopping checkout complete \ +gddy shopping checkout complete \ --file complete-checkout.json ``` -Completion returns immediately after the single purchase attempt. It never automatically +Completion returns immediately after the single purchase attempt. A successful response includes +the order ID and a **View order** permalink for the customer's account. It never automatically retries. If the result is uncertain, first confirm the outcome; only then reuse the same effective idempotency key for the same intended purchase. @@ -155,13 +187,13 @@ New orders normally become available 3–10 seconds after completion. Retrieve t separately, optionally polling for up to 15 seconds by default: ```bash -gddy --env test shopping order get --wait --wait-timeout 15 +gddy shopping order get --wait --wait-timeout 15 ``` The engine-wide `--timeout` remains independent of order visibility waiting: ```bash -gddy --env test --timeout 30s shopping order get \ +gddy --timeout 30s shopping order get \ --wait --wait-timeout 15 ``` From ad1232d6d2c90777029c9098b4c3dc4625a9cb0f Mon Sep 17 00:00:00 2001 From: sswaminathan Date: Wed, 9 Sep 2026 18:28:57 -0700 Subject: [PATCH 08/14] fix(shopping): improve human command output Co-Authored-By: Claude --- rust/src/shopping/catalog/get.rs | 216 ++++++++++++++++++++++++- rust/src/shopping/catalog/mod.rs | 2 +- rust/src/shopping/checkout/complete.rs | 30 ++-- rust/src/shopping/checkout/create.rs | 22 ++- rust/src/shopping/checkout/get.rs | 90 +++++++---- rust/src/shopping/common.rs | 20 ++- rust/src/shopping/mod.rs | 4 + rust/src/shopping/order/get.rs | 175 ++++++++++++++++++-- rust/src/shopping/order/mod.rs | 2 +- 9 files changed, 495 insertions(+), 66 deletions(-) diff --git a/rust/src/shopping/catalog/get.rs b/rust/src/shopping/catalog/get.rs index 39cc12cb..8b6cdf9b 100644 --- a/rust/src/shopping/catalog/get.rs +++ b/rust/src/shopping/catalog/get.rs @@ -1,10 +1,13 @@ -use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use cli_engine::{CommandResult, CommandSpec, ModuleContext, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; +use crate::next_action::{next_action, required_value}; use crate::output_schema::output_schema; -use crate::shopping::SHOPPING_SCOPES; use crate::shopping::common::{ client_err, currency_code, make_client, merge_context_currency, read_json, }; +use crate::shopping::money; +use crate::shopping::{SHOPPING_SCOPES, command_for_env}; output_schema!(CatalogProductOutput { "ucp": "object"; @@ -26,6 +29,14 @@ struct Args { currency: Option, } +const HUMAN_VIEW_ID: &str = "shopping-catalog-get"; + +pub(crate) fn register_human_view(ctx: &mut ModuleContext<'_>) { + ctx.middleware_mut() + .human_views + .register_func(HUMAN_VIEW_ID, render_human); +} + pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("get", "Get one Shopping catalog product") @@ -33,14 +44,207 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("shopping") .with_tier(Tier::Read) .with_scopes(SHOPPING_SCOPES) - .with_output_schema::(), + .with_output_schema::() + .with_view_id(HUMAN_VIEW_ID), |ctx, args: Args| async move { let mut body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; merge_context_currency(&mut body, args.currency.as_deref())?; let client = make_client(&ctx).await?; - Ok(CommandResult::new( - client.catalog_product(body).await.map_err(client_err)?, - )) + let response = client.catalog_product(body).await.map_err(client_err)?; + let actions = next_actions(&response, &ctx.middleware.env); + let output = if ctx.middleware.output_format == "human" { + human_response(&response, &actions) + } else { + response + }; + Ok(CommandResult::new(output).with_next_actions(actions)) }, ) } + +fn next_actions(response: &Value, env: &str) -> Vec { + let Some(product) = response.get("product") else { + return Vec::new(); + }; + let Some((variant_id, currency)) = + product + .get("variants") + .and_then(Value::as_array) + .and_then(|variants| { + variants.iter().find_map(|variant| { + let available = variant + .pointer("/availability/available") + .and_then(Value::as_bool) + .unwrap_or(false); + let id = variant.get("id").and_then(Value::as_str)?; + available.then(|| { + ( + id, + variant + .pointer("/price/currency") + .and_then(Value::as_str) + .unwrap_or("USD"), + ) + }) + }) + }) + else { + return Vec::new(); + }; + let body = json!({ + "context": {"currency": currency}, + "line_items": [{"item": {"id": variant_id}, "quantity": 1}] + }) + .to_string(); + vec![ + next_action( + command_for_env(env, format!("checkout create --body '{body}'")), + "Create a checkout with the first available variant", + ) + .with_param("variant_id", required_value(variant_id)), + ] +} + +fn human_response(response: &Value, actions: &[cli_engine::NextAction]) -> Value { + let product = response.get("product").cloned().unwrap_or(Value::Null); + json!({ + "id": product.get("id").and_then(Value::as_str).unwrap_or_default(), + "title": product.get("title").and_then(Value::as_str).unwrap_or("Untitled product"), + "description": product.pointer("/description/plain").and_then(Value::as_str), + "categories": product.get("categories").and_then(Value::as_array).map(|categories| categories.iter().filter_map(|category| category.get("value").and_then(Value::as_str)).collect::>()).unwrap_or_default(), + "price_range": product.get("price_range").cloned(), + "variants": product.get("variants").cloned().unwrap_or_else(|| json!([])), + "next_steps": actions.iter().map(|action| json!({"command": action.command, "description": action.description})).collect::>(), + }) +} + +fn render_human(product: &Value) -> String { + let mut output = format!( + "{} (ID: {})\n", + product + .get("title") + .and_then(Value::as_str) + .unwrap_or("Untitled product"), + product + .get("id") + .and_then(Value::as_str) + .unwrap_or_default(), + ); + if let Some(description) = product.get("description").and_then(Value::as_str) { + output.push_str(&format!("{description}\n")); + } + let categories = product + .get("categories") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + if !categories.is_empty() { + output.push_str(&format!( + "Category: {}\n", + categories + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(", ") + )); + } + if let Some(range) = product.get("price_range") { + let min = money(range.get("min")); + let max = money(range.get("max")); + if let Some(price_range) = match (min, max) { + (Some(min), Some(max)) if min == max => Some(min), + (Some(min), Some(max)) => Some(format!("{min}–{max}")), + _ => None, + } { + output.push_str(&format!("Price range: {price_range}\n")); + } + } + output.push_str("\nVariants:\n"); + let variants = product + .get("variants") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + if variants.is_empty() { + output.push_str("- None\n"); + } + for variant in variants { + let availability = if variant + .pointer("/availability/available") + .and_then(Value::as_bool) + .unwrap_or(false) + { + "Available" + } else { + "Unavailable" + }; + output.push_str(&format!( + "- {} (ID: {})\n Price: {} · List price: {} · {availability}\n", + variant + .get("title") + .and_then(Value::as_str) + .unwrap_or("Untitled variant"), + variant + .get("id") + .and_then(Value::as_str) + .unwrap_or_default(), + money(variant.get("price")).unwrap_or_else(|| "Unavailable".to_owned()), + money(variant.get("list_price")).unwrap_or_else(|| "Unavailable".to_owned()), + )); + } + render_next_steps(&mut output, product); + output +} + +fn render_next_steps(output: &mut String, response: &Value) { + let steps = response + .get("next_steps") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + if steps.is_empty() { + return; + } + output.push_str("\nNext steps:\n"); + for step in steps { + output.push_str(&format!( + " {}\n {}\n", + step.get("command") + .and_then(Value::as_str) + .unwrap_or_default(), + step.get("description") + .and_then(Value::as_str) + .unwrap_or_default(), + )); + } +} + +fn money(value: Option<&Value>) -> Option { + money::format_value(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn human_view_shows_product_essentials_and_checkout_action() { + let response = json!({ + "product": { + "id": "product-1", + "title": "Product", + "description": {"plain": "Description"}, + "categories": [{"value": "email"}], + "price_range": {"min": {"amount": 7188, "currency": "USD"}, "max": {"amount": 7188, "currency": "USD"}}, + "variants": [{"id": "variant-1", "title": "One year", "availability": {"available": true}, "price": {"amount": 7188, "currency": "USD"}, "list_price": {"amount": 11988, "currency": "USD"}}] + }, + "ucp": {"do_not_render": true} + }); + let output = render_human(&human_response(&response, &next_actions(&response, "test"))); + + assert!(output.contains("Product (ID: product-1)")); + assert!(output.contains("USD 71.88")); + assert!(output.contains("checkout create")); + assert!(!output.contains("do_not_render")); + } +} diff --git a/rust/src/shopping/catalog/mod.rs b/rust/src/shopping/catalog/mod.rs index a619f227..982c0838 100644 --- a/rust/src/shopping/catalog/mod.rs +++ b/rust/src/shopping/catalog/mod.rs @@ -1,4 +1,4 @@ -mod get; +pub(super) mod get; mod lookup; pub(super) mod search; diff --git a/rust/src/shopping/checkout/complete.rs b/rust/src/shopping/checkout/complete.rs index 01d13437..ddf9530b 100644 --- a/rust/src/shopping/checkout/complete.rs +++ b/rust/src/shopping/checkout/complete.rs @@ -32,13 +32,18 @@ pub(crate) fn register_human_view(ctx: &mut ModuleContext<'_>) { .register_func(HUMAN_VIEW_ID, render_human); } -fn human_response(completion: &Value, idempotency_key: &str) -> Value { +fn human_response( + completion: &Value, + idempotency_key: &str, + actions: &[cli_engine::NextAction], +) -> Value { json!({ "checkout_id": completion.get("id").and_then(Value::as_str).unwrap_or_default(), "status": completion.get("status").and_then(Value::as_str).unwrap_or_default(), "order_id": completion.pointer("/order/id").and_then(Value::as_str), "order_permalink": completion.pointer("/order/permalink_url").and_then(Value::as_str), "idempotency_key": idempotency_key, + "next_steps": actions.iter().map(|action| json!({"command": action.command, "description": action.description})).collect::>(), }) } @@ -81,6 +86,7 @@ fn render_human(completion: &Value) -> String { output.push_str(&format!("View order: {permalink}\n")); } output.push_str("\nKeep this idempotency key. Do not retry a completion unless you first confirm its outcome.\n"); + crate::shopping::checkout::get::render_next_steps(&mut output, completion); output } @@ -137,13 +143,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .pointer("/order/id") .and_then(Value::as_str) .map(str::to_owned); - let mut result = CommandResult::new(if ctx.middleware.output_format == "human" { - human_response(&completion, &idempotency_key) - } else { - completion - }); - if let Some(order_id) = order_id { - result = result.with_next_actions(vec![ + let actions = order_id.map_or_else(Vec::new, |order_id| { + vec![ next_action( command_for_env( &ctx.middleware.env, @@ -152,10 +153,15 @@ pub(super) fn command() -> RuntimeCommandSpec { "Read the completed order after it becomes visible", ) .with_param("order_id", required_value(order_id)) - .with_param("idempotency_key", required_value(idempotency_key)), - ]); - } - Ok(result) + .with_param("idempotency_key", required_value(idempotency_key.clone())), + ] + }); + let output = if ctx.middleware.output_format == "human" { + human_response(&completion, &idempotency_key, &actions) + } else { + completion + }; + Ok(CommandResult::new(output).with_next_actions(actions)) }, ) } diff --git a/rust/src/shopping/checkout/create.rs b/rust/src/shopping/checkout/create.rs index 1c2ddace..818b500e 100644 --- a/rust/src/shopping/checkout/create.rs +++ b/rust/src/shopping/checkout/create.rs @@ -3,6 +3,7 @@ use serde_json::Value; use crate::next_action::{next_action, required_value}; use crate::output_schema::output_schema; +use crate::shopping::checkout::get::{HUMAN_VIEW_ID, human_response}; use crate::shopping::common::{client_err, make_client, read_json}; use crate::shopping::{SHOPPING_SCOPES, command_for_env}; @@ -44,7 +45,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .handles_dry_run(true) .with_scopes(SHOPPING_SCOPES) .auth_optional() - .with_output_schema::(), + .with_output_schema::() + .with_view_id(HUMAN_VIEW_ID), |ctx, args: Args| async move { let body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; if ctx.dry_run() { @@ -62,9 +64,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .and_then(Value::as_str) .unwrap_or_default() .to_owned(); - let mut result = CommandResult::new(checkout); - if ready_for_complete { - result = result.with_next_actions(vec![ + let actions = if ready_for_complete { + vec![ next_action( command_for_env( &ctx.middleware.env, @@ -73,9 +74,16 @@ pub(super) fn command() -> RuntimeCommandSpec { "Complete this checkout with a selected saved payment instrument", ) .with_param("checkout_id", required_value(checkout_id)), - ]); - } - Ok(result) + ] + } else { + Vec::new() + }; + let output = if ctx.middleware.output_format == "human" { + human_response(&checkout, &actions) + } else { + checkout + }; + Ok(CommandResult::new(output).with_next_actions(actions)) }, ) } diff --git a/rust/src/shopping/checkout/get.rs b/rust/src/shopping/checkout/get.rs index 6795a034..facd994e 100644 --- a/rust/src/shopping/checkout/get.rs +++ b/rust/src/shopping/checkout/get.rs @@ -37,14 +37,8 @@ pub(super) fn command() -> RuntimeCommandSpec { let checkout = client_response(&ctx, &args.id).await?; let ready_for_complete = checkout.get("status").and_then(Value::as_str) == Some("ready_for_complete"); - let output = if ctx.middleware.output_format == "human" { - human_response(&checkout) - } else { - checkout - }; - let mut result = CommandResult::new(output); - if ready_for_complete { - result = result.with_next_actions(vec![next_action( + let actions = if ready_for_complete { + vec![next_action( command_for_env( &ctx.middleware.env, format!( @@ -53,9 +47,16 @@ pub(super) fn command() -> RuntimeCommandSpec { ), ), "Complete this checkout after reviewing its selected payment method", - )]); - } - Ok(result) + )] + } else { + Vec::new() + }; + let output = if ctx.middleware.output_format == "human" { + human_response(&checkout, &actions) + } else { + checkout + }; + Ok(CommandResult::new(output).with_next_actions(actions)) }, ) } @@ -65,7 +66,7 @@ async fn client_response(ctx: &cli_engine::CommandContext, id: &str) -> Result Value { +pub(super) fn human_response(checkout: &Value, actions: &[cli_engine::NextAction]) -> Value { let line_items = checkout .get("line_items") .and_then(Value::as_array) @@ -89,6 +90,7 @@ pub(super) fn human_response(checkout: &Value) -> Value { "currency": checkout.get("currency").and_then(Value::as_str).unwrap_or_default(), "totals": checkout.get("totals").cloned().unwrap_or_else(|| json!([])), "selected_payment": selected_payment(checkout), + "next_steps": actions.iter().map(|action| json!({"command": action.command, "description": action.description})).collect::>(), }) } @@ -115,6 +117,13 @@ fn selected_payment(checkout: &Value) -> String { } fn render_human(checkout: &Value) -> String { + if let Some(action) = checkout.get("action").and_then(Value::as_str) { + let body = checkout.get("body").cloned().unwrap_or(Value::Null); + return format!( + "{action}\nRequest:\n{}\n", + serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string()), + ); + } let mut output = format!( "Checkout: {}\nStatus: {}\n", checkout @@ -191,30 +200,57 @@ fn render_human(checkout: &Value) -> String { )); } output.push_str("\nCompletion places a real order. Review this checkout before continuing.\n"); + render_next_steps(&mut output, checkout); output } +pub(super) fn render_next_steps(output: &mut String, response: &Value) { + let steps = response + .get("next_steps") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + if steps.is_empty() { + return; + } + output.push_str("\nNext steps:\n"); + for step in steps { + output.push_str(&format!( + " {}\n {}\n", + step.get("command") + .and_then(Value::as_str) + .unwrap_or_default(), + step.get("description") + .and_then(Value::as_str) + .unwrap_or_default(), + )); + } +} + #[cfg(test)] mod tests { use super::*; #[test] fn human_view_masks_checkout_to_purchase_essentials() { - let output = render_human(&human_response(&json!({ - "id": "checkout-1", - "status": "ready_for_complete", - "line_items": [{ - "quantity": 1, - "item": {"title": "Web Hosting Economy"}, - "included_products": [{"title": "Standard SSL"}] - }], - "payment": {"instruments": [{ - "selected": true, - "rich_text_description": "CREDIT_CARD/VISA 1111", - "billing_address": {"street_address": "do not render"} - }]}, - "totals": [{"display_text": "Total", "amount": 8388}] - }))); + let output = render_human(&human_response( + &json!({ + "id": "checkout-1", + "status": "ready_for_complete", + "line_items": [{ + "quantity": 1, + "item": {"title": "Web Hosting Economy"}, + "included_products": [{"title": "Standard SSL"}] + }], + "payment": {"instruments": [{ + "selected": true, + "rich_text_description": "CREDIT_CARD/VISA 1111", + "billing_address": {"street_address": "do not render"} + }]}, + "totals": [{"display_text": "Total", "amount": 8388}] + }), + &[], + )); assert!(output.contains("Web Hosting Economy")); assert!(output.contains("CREDIT_CARD/VISA 1111")); diff --git a/rust/src/shopping/common.rs b/rust/src/shopping/common.rs index 17e3be1f..82210d30 100644 --- a/rust/src/shopping/common.rs +++ b/rust/src/shopping/common.rs @@ -151,6 +151,7 @@ pub(crate) async fn wait_for_order( client: &ShoppingClient, order_id: &str, timeout: Duration, + env: &str, ) -> Result<(Value, usize)> { let started = Instant::now(); let mut attempts = 0; @@ -179,7 +180,10 @@ pub(crate) async fn wait_for_order( "order {order_id:?} was not visible after {attempts} attempts over {} seconds", timeout.as_secs_f32() )) - .with_fix(format!("Run: gddy shopping order get {order_id} --wait")) + .with_fix(format!( + "Run: gddy {}", + crate::shopping::command_for_env(env, format!("order get {order_id} --wait")) + )) .into_cli_error()); } Err(error) => return Err(client_err(error)), @@ -189,7 +193,10 @@ pub(crate) async fn wait_for_order( "order {order_id:?} was not visible after {attempts} attempts over {} seconds", timeout.as_secs_f32() )) - .with_fix(format!("Run: gddy shopping order get {order_id} --wait")) + .with_fix(format!( + "Run: gddy {}", + crate::shopping::command_for_env(env, format!("order get {order_id} --wait")) + )) .into_cli_error()) } @@ -210,6 +217,7 @@ mod tests { use serde_json::json; use super::*; + use crate::shopping::command_for_env; #[test] fn generates_and_inserts_missing_completion_idempotency_key() { @@ -259,6 +267,14 @@ mod tests { ); } + #[test] + fn preserves_named_environment_in_order_wait_recovery_command() { + assert_eq!( + command_for_env("test", "order get order-1 --wait"), + "--env test shopping order get order-1 --wait" + ); + } + #[test] fn validates_wait_timeout_range() { assert_eq!( diff --git a/rust/src/shopping/mod.rs b/rust/src/shopping/mod.rs index 8e2e5f7c..408a3ee0 100644 --- a/rust/src/shopping/mod.rs +++ b/rust/src/shopping/mod.rs @@ -8,9 +8,11 @@ mod order; use cli_engine::{GroupSpec, Module, RuntimeGroupSpec}; +use crate::shopping::catalog::get::register_human_view as register_catalog_get_human_view; use crate::shopping::catalog::search::register_human_view as register_catalog_search_human_view; use crate::shopping::checkout::complete::register_human_view as register_checkout_complete_human_view; use crate::shopping::checkout::get::register_human_view as register_checkout_get_human_view; +use crate::shopping::order::get::register_human_view as register_order_get_human_view; use crate::scopes::{SHOPPING_CATALOG_READ, SHOPPING_CHECKOUT_EXECUTE, SHOPPING_ORDER_READ}; @@ -33,9 +35,11 @@ pub(crate) fn command_for_env(env: &str, command: impl AsRef) -> String { pub fn module() -> Module { Module::new("Shopping", |ctx| { + register_catalog_get_human_view(ctx); register_catalog_search_human_view(ctx); register_checkout_complete_human_view(ctx); register_checkout_get_human_view(ctx); + register_order_get_human_view(ctx); RuntimeGroupSpec::new( GroupSpec::new( "shopping", diff --git a/rust/src/shopping/order/get.rs b/rust/src/shopping/order/get.rs index 5e96e842..2f4da4fe 100644 --- a/rust/src/shopping/order/get.rs +++ b/rust/src/shopping/order/get.rs @@ -1,8 +1,10 @@ -use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use cli_engine::{CommandResult, CommandSpec, ModuleContext, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; use crate::output_schema::output_schema; use crate::shopping::SHOPPING_SCOPES; use crate::shopping::common::{client_err, make_client, wait_duration, wait_for_order}; +use crate::shopping::money; output_schema!(OrderOutput { "ucp": "object"; @@ -27,6 +29,14 @@ struct Args { wait_timeout: Option, } +const HUMAN_VIEW_ID: &str = "shopping-order-get"; + +pub(crate) fn register_human_view(ctx: &mut ModuleContext<'_>) { + ctx.middleware_mut() + .human_views + .register_func(HUMAN_VIEW_ID, render_human); +} + pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("get", "Get a completed Shopping order") @@ -37,18 +47,163 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("shopping") .with_tier(Tier::Read) .with_scopes(SHOPPING_SCOPES) - .with_output_schema::(), + .with_output_schema::() + .with_view_id(HUMAN_VIEW_ID), |ctx, args: Args| async move { let client = make_client(&ctx).await?; - if args.wait { - let (order, _) = - wait_for_order(&client, &args.id, wait_duration(args.wait_timeout)?).await?; - Ok(CommandResult::new(order)) + let order = if args.wait { + let (order, _) = wait_for_order( + &client, + &args.id, + wait_duration(args.wait_timeout)?, + &ctx.middleware.env, + ) + .await?; + order } else { - Ok(CommandResult::new( - client.get_order(&args.id).await.map_err(client_err)?, - )) - } + client.get_order(&args.id).await.map_err(client_err)? + }; + let output = if ctx.middleware.output_format == "human" { + human_response(&order) + } else { + order + }; + Ok(CommandResult::new(output)) }, ) } + +fn human_response(order: &Value) -> Value { + json!({ + "id": order.get("id").and_then(Value::as_str).unwrap_or_default(), + "permalink_url": order.get("permalink_url").and_then(Value::as_str), + "line_items": order.get("line_items").cloned().unwrap_or_else(|| json!([])), + "totals": order.get("totals").cloned().unwrap_or_else(|| json!([])), + "currency": order.get("currency").and_then(Value::as_str), + "fulfillment": order.get("fulfillment").cloned().unwrap_or_else(|| json!({})), + }) +} + +fn render_human(order: &Value) -> String { + let mut output = format!( + "Order: {}\n", + order.get("id").and_then(Value::as_str).unwrap_or_default(), + ); + if let Some(permalink) = order.get("permalink_url").and_then(Value::as_str) { + output.push_str(&format!("View order: {permalink}\n")); + } + output.push_str("\nItems:\n"); + let items = order + .get("line_items") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + if items.is_empty() { + output.push_str("- None\n"); + } + for item in items { + let quantity = item + .pointer("/quantity/total") + .or_else(|| item.get("quantity")) + .and_then(Value::as_u64) + .unwrap_or(1); + let title = item + .pointer("/item/title") + .and_then(Value::as_str) + .unwrap_or("Unknown item"); + let status = item + .get("status") + .and_then(Value::as_str) + .unwrap_or("unknown"); + output.push_str(&format!("- {quantity} × {title} · {status}\n")); + } + if order.get("currency").and_then(Value::as_str).is_some() { + output.push_str("\nTotals:\n"); + render_totals(&mut output, order.get("totals"), order.get("currency")); + } + render_fulfillment(&mut output, order.get("fulfillment")); + output +} + +fn render_totals(output: &mut String, totals: Option<&Value>, currency: Option<&Value>) { + let Some(currency) = currency.and_then(Value::as_str) else { + return; + }; + let totals = totals + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + if totals.is_empty() { + output.push_str("- None\n"); + } + for total in totals { + let label = total + .get("display_text") + .or_else(|| total.get("type")) + .and_then(Value::as_str) + .unwrap_or("Total"); + let amount = total + .get("amount") + .and_then(Value::as_i64) + .unwrap_or_default(); + output.push_str(&format!( + "- {label}: {}\n", + money::format_amount(amount, currency) + )); + } +} + +fn render_fulfillment(output: &mut String, fulfillment: Option<&Value>) { + let Some(fulfillment) = fulfillment.and_then(Value::as_object) else { + return; + }; + let expectations = fulfillment + .get("expectations") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + if expectations.is_empty() { + return; + } + output.push_str("\nFulfillment:\n"); + for expectation in expectations { + if let Some(status) = expectation.get("status").and_then(Value::as_str) { + output.push_str(&format!("- {status}\n")); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn human_view_shows_order_essentials_without_ucp_metadata() { + let output = render_human(&human_response(&json!({ + "id": "order-1", + "checkout_id": "checkout-1", + "permalink_url": "https://example.test/order-1", + "currency": "USD", + "line_items": [{"item": {"title": "Web Hosting Economy"}, "quantity": {"total": 1}, "status": "fulfilled"}], + "totals": [{"display_text": "Total", "amount": 8388}], + "ucp": {"do_not_render": true} + }))); + + assert!(output.contains("Order: order-1")); + assert!(output.contains("Web Hosting Economy · fulfilled")); + assert!(output.contains("USD 83.88")); + assert!(!output.contains("Checkout:")); + assert!(!output.contains("do_not_render")); + } + + #[test] + fn human_view_omits_totals_without_a_currency() { + let output = render_human(&human_response(&json!({ + "id": "order-1", + "totals": [{"display_text": "Total", "amount": 8388}] + }))); + + assert!(!output.contains("Totals:")); + assert!(!output.contains("8388")); + } +} diff --git a/rust/src/shopping/order/mod.rs b/rust/src/shopping/order/mod.rs index b53c348a..0757ad1b 100644 --- a/rust/src/shopping/order/mod.rs +++ b/rust/src/shopping/order/mod.rs @@ -1,4 +1,4 @@ -mod get; +pub(super) mod get; use cli_engine::{GroupSpec, RuntimeGroupSpec}; From 01a779c1335187ad95f72572b7c8d8a57b78aa88 Mon Sep 17 00:00:00 2001 From: sswaminathan Date: Wed, 9 Sep 2026 18:43:54 -0700 Subject: [PATCH 09/14] fix(shopping): suppress sensitive transport logs Co-Authored-By: Claude --- rust/src/shopping/client.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/rust/src/shopping/client.rs b/rust/src/shopping/client.rs index bdc4259f..635ee6fc 100644 --- a/rust/src/shopping/client.rs +++ b/rust/src/shopping/client.rs @@ -84,7 +84,6 @@ impl ShoppingClient { request = request.json(&body); } let request = request.build()?; - cli_engine::transport::debug_log_reqwest_request(&request); let response = self.client.execute(request).await?; let status = response.status(); let headers = response.headers().clone(); @@ -94,7 +93,6 @@ impl ShoppingClient { .and_then(|value| value.parse::().ok()) .map(Duration::from_secs); let bytes = response.bytes().await?; - cli_engine::transport::debug_log_reqwest_response(status, &headers, &bytes); let status = status.as_u16(); if status == 204 || bytes.is_empty() { From 20e0d2a3c146145deadbe3dd5ce025475f9f67d1 Mon Sep 17 00:00:00 2001 From: sswaminathan Date: Wed, 9 Sep 2026 21:07:15 -0700 Subject: [PATCH 10/14] feat(shopping): simplify checkout inputs Co-Authored-By: Claude --- rust/src/shopping/catalog/get.rs | 10 +- rust/src/shopping/catalog/search.rs | 12 +- rust/src/shopping/checkout/complete.rs | 48 ++- rust/src/shopping/checkout/create.rs | 94 +++++- rust/src/shopping/checkout/get.rs | 31 +- rust/src/shopping/checkout/update.rs | 96 +++++- rust/src/shopping/common.rs | 392 ++++++++++++++++++++++++- rust/src/shopping/guides/shopping.md | 110 ++++--- 8 files changed, 680 insertions(+), 113 deletions(-) diff --git a/rust/src/shopping/catalog/get.rs b/rust/src/shopping/catalog/get.rs index 8b6cdf9b..fb7e1935 100644 --- a/rust/src/shopping/catalog/get.rs +++ b/rust/src/shopping/catalog/get.rs @@ -91,14 +91,12 @@ fn next_actions(response: &Value, env: &str) -> Vec { else { return Vec::new(); }; - let body = json!({ - "context": {"currency": currency}, - "line_items": [{"item": {"id": variant_id}, "quantity": 1}] - }) - .to_string(); vec![ next_action( - command_for_env(env, format!("checkout create --body '{body}'")), + command_for_env( + env, + format!("checkout create --item '{variant_id}' --currency {currency}"), + ), "Create a checkout with the first available variant", ) .with_param("variant_id", required_value(variant_id)), diff --git a/rust/src/shopping/catalog/search.rs b/rust/src/shopping/catalog/search.rs index 6c4c5e29..e257c790 100644 --- a/rust/src/shopping/catalog/search.rs +++ b/rust/src/shopping/catalog/search.rs @@ -198,14 +198,12 @@ fn product_actions(response: &Value, request: &Value, env: &str) -> Vec, + + /// Stable key for this single intended purchase. A UUID is generated when omitted. + #[arg(long, value_name = "KEY")] + idempotency_key: Option, + + /// Completion request as raw JSON for advanced payment or billing-address fields. + #[arg(long, value_name = "JSON")] body: Option, /// Path to a JSON completion request. Takes precedence over --body. @@ -103,11 +111,10 @@ pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("complete", "Complete a Shopping checkout and place an order") .with_long( - "Places a real order. Supply the Shopping API completion request through --body or \ - --file; it must include a selected saved payment instrument. Supply a non-empty \ - idempotency_key to control retries, or omit it to let gddy generate and return one. \ - The CLI never retries completion automatically. Read the resulting order with \ - `shopping order get --wait`.", + "Places a real order. Use --payment-instrument for one saved payment instrument, \ + or --body/--file for advanced payment or billing-address fields. Use --idempotency-key \ + to control retries, or omit it to let gddy generate and return one. The CLI never retries \ + completion automatically. Read the resulting order with `shopping order get --wait`.", ) .with_system("shopping") .with_tier(Tier::Mutate) @@ -117,7 +124,28 @@ pub(super) fn command() -> RuntimeCommandSpec { .auth_optional() .with_view_id(HUMAN_VIEW_ID), |ctx, args: Args| async move { - let mut body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + let input = CheckoutInput { + payment_instrument: args.payment_instrument, + ..CheckoutInput::default() + }; + reject_mixed_checkout_input(args.body.as_deref(), args.file.as_deref(), input.is_present())?; + if args.idempotency_key.as_deref().is_some_and(|key| key.trim().is_empty()) { + return Err(crate::error::GddyError::validation( + "--idempotency-key must be non-empty when supplied", + ) + .into_cli_error()); + } + let mut body = if args.body.is_some() || args.file.is_some() { + let mut body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + if let Some(idempotency_key) = args.idempotency_key { + body.as_object_mut() + .expect("read_json validates the completion request is an object") + .insert("idempotency_key".to_owned(), json!(idempotency_key)); + } + body + } else { + input.completion_body(args.idempotency_key.as_deref())? + }; if has_conflicting_checkout_id(&body, &args.id) { return Err(crate::error::GddyError::validation( "checkout ID in request body conflicts with CHECKOUT_ID", diff --git a/rust/src/shopping/checkout/create.rs b/rust/src/shopping/checkout/create.rs index 818b500e..5f491ae1 100644 --- a/rust/src/shopping/checkout/create.rs +++ b/rust/src/shopping/checkout/create.rs @@ -4,7 +4,10 @@ use serde_json::Value; use crate::next_action::{next_action, required_value}; use crate::output_schema::output_schema; use crate::shopping::checkout::get::{HUMAN_VIEW_ID, human_response}; -use crate::shopping::common::{client_err, make_client, read_json}; +use crate::shopping::common::{ + CheckoutInput, client_err, currency_code, make_client, no_saved_payment_method_action, + read_json, reject_mixed_checkout_input, reject_multiple_payment_instruments, +}; use crate::shopping::{SHOPPING_SCOPES, command_for_env}; output_schema!(CheckoutOutput { @@ -21,8 +24,36 @@ output_schema!(CheckoutOutput { #[derive(Debug, Clone, clap::Args)] struct Args { - /// Checkout-create request as raw JSON. - #[arg(long, value_name = "JSON", required_unless_present = "file")] + /// Variant ID to add to the checkout. Repeat for multiple items; append `=QUANTITY` to set a quantity. + #[arg(long, value_name = "VARIANT_ID[=QUANTITY]")] + item: Vec, + + /// Preferred ISO 4217 currency for checkout prices (for example, USD or GBP). + #[arg(long, value_name = "CODE", value_parser = currency_code)] + currency: Option, + + /// Buyer's first name. + #[arg(long, value_name = "NAME")] + buyer_first_name: Option, + + /// Buyer's last name. + #[arg(long, value_name = "NAME")] + buyer_last_name: Option, + + /// Buyer's email address. + #[arg(long, value_name = "EMAIL")] + buyer_email: Option, + + /// Buyer's phone number. + #[arg(long, value_name = "PHONE")] + buyer_phone: Option, + + /// Select one saved payment instrument by ID. + #[arg(long, value_name = "INSTRUMENT_ID")] + payment_instrument: Option, + + /// Checkout-create request as raw JSON for advanced Shopping API fields. + #[arg(long, value_name = "JSON")] body: Option, /// Path to a JSON checkout-create request. Takes precedence over --body. @@ -34,10 +65,10 @@ pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("create", "Create a Shopping checkout session") .with_long( - "Create a checkout from a Shopping API request object. Supply it with --body or \ - --file; `gddy guide shopping` documents the required line_items format. This \ - creates a checkout but does not place an order; use `checkout complete` only after \ - reviewing the checkout.", + "Create a checkout with --item and optional buyer/payment flags. Repeat --item for \ + multiple variants and append =QUANTITY when needed. Use --body or --file for \ + advanced Shopping API fields such as item input, fulfillment, or billing addresses. \ + This creates a checkout but does not place an order; complete it only after review.", ) .with_system("shopping") .with_tier(Tier::Mutate) @@ -48,7 +79,23 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_output_schema::() .with_view_id(HUMAN_VIEW_ID), |ctx, args: Args| async move { - let body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + let input = CheckoutInput { + items: args.item, + currency: args.currency, + buyer_first_name: args.buyer_first_name, + buyer_last_name: args.buyer_last_name, + buyer_email: args.buyer_email, + buyer_phone: args.buyer_phone, + payment_instrument: args.payment_instrument, + ..CheckoutInput::default() + }; + reject_mixed_checkout_input(args.body.as_deref(), args.file.as_deref(), input.is_present())?; + let body = if args.body.is_some() || args.file.is_some() { + read_json(args.body.as_deref(), args.file.as_deref(), "object")? + } else { + input.create_body()? + }; + reject_multiple_payment_instruments(&body)?; if ctx.dry_run() { return Ok(CommandResult::new(serde_json::json!({ "action": "dry-run: would create checkout", @@ -64,20 +111,37 @@ pub(super) fn command() -> RuntimeCommandSpec { .and_then(Value::as_str) .unwrap_or_default() .to_owned(); - let actions = if ready_for_complete { - vec![ + let env = crate::environments::resolve(&ctx.middleware.env)?; + let mut actions = no_saved_payment_method_action( + &checkout, + &ctx.middleware.env, + &env.account_url, + ) + .into_iter() + .collect::>(); + if ready_for_complete { + let payment_instrument = checkout + .pointer("/payment/instruments") + .and_then(Value::as_array) + .and_then(|instruments| { + instruments.iter().find(|instrument| { + instrument.get("selected").and_then(Value::as_bool) == Some(true) + }) + }) + .and_then(|instrument| instrument.get("id")) + .and_then(Value::as_str) + .unwrap_or(""); + actions.push( next_action( command_for_env( &ctx.middleware.env, - format!("checkout complete {checkout_id} --file complete-checkout.json"), + format!("checkout complete {checkout_id} --payment-instrument {payment_instrument}"), ), "Complete this checkout with a selected saved payment instrument", ) .with_param("checkout_id", required_value(checkout_id)), - ] - } else { - Vec::new() - }; + ); + } let output = if ctx.middleware.output_format == "human" { human_response(&checkout, &actions) } else { diff --git a/rust/src/shopping/checkout/get.rs b/rust/src/shopping/checkout/get.rs index facd994e..70752fcb 100644 --- a/rust/src/shopping/checkout/get.rs +++ b/rust/src/shopping/checkout/get.rs @@ -38,11 +38,13 @@ pub(super) fn command() -> RuntimeCommandSpec { let ready_for_complete = checkout.get("status").and_then(Value::as_str) == Some("ready_for_complete"); let actions = if ready_for_complete { + let payment_instrument = + selected_payment_id(&checkout).unwrap_or(""); vec![next_action( command_for_env( &ctx.middleware.env, format!( - "checkout complete {} --file complete-checkout.json", + "checkout complete {} --payment-instrument {payment_instrument}", args.id ), ), @@ -95,15 +97,7 @@ pub(super) fn human_response(checkout: &Value, actions: &[cli_engine::NextAction } fn selected_payment(checkout: &Value) -> String { - let Some(instrument) = checkout - .pointer("/payment/instruments") - .and_then(Value::as_array) - .and_then(|instruments| { - instruments.iter().find(|instrument| { - instrument.get("selected").and_then(Value::as_bool) == Some(true) - }) - }) - else { + let Some(instrument) = selected_payment_instrument(checkout) else { return "No payment method selected".to_owned(); }; let description = instrument @@ -116,6 +110,23 @@ fn selected_payment(checkout: &Value) -> String { } } +fn selected_payment_instrument(checkout: &Value) -> Option<&Value> { + checkout + .pointer("/payment/instruments") + .and_then(Value::as_array) + .and_then(|instruments| { + instruments.iter().find(|instrument| { + instrument.get("selected").and_then(Value::as_bool) == Some(true) + }) + }) +} + +fn selected_payment_id(checkout: &Value) -> Option<&str> { + selected_payment_instrument(checkout) + .and_then(|instrument| instrument.get("id"))? + .as_str() +} + fn render_human(checkout: &Value) -> String { if let Some(action) = checkout.get("action").and_then(Value::as_str) { let body = checkout.get("body").cloned().unwrap_or(Value::Null); diff --git a/rust/src/shopping/checkout/update.rs b/rust/src/shopping/checkout/update.rs index e49680db..242ca0ba 100644 --- a/rust/src/shopping/checkout/update.rs +++ b/rust/src/shopping/checkout/update.rs @@ -1,7 +1,12 @@ use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; use crate::shopping::SHOPPING_SCOPES; -use crate::shopping::common::{client_err, has_conflicting_checkout_id, make_client, read_json}; +use crate::shopping::checkout::get::HUMAN_VIEW_ID; +use crate::shopping::common::{ + CheckoutInput, client_err, currency_code, has_conflicting_checkout_id, make_client, + no_saved_payment_method_action, read_json, reject_mixed_checkout_input, + reject_multiple_payment_instruments, +}; #[derive(Debug, Clone, clap::Args)] struct Args { @@ -9,8 +14,40 @@ struct Args { #[arg(value_name = "CHECKOUT_ID")] id: String, - /// Full checkout replacement as raw JSON. - #[arg(long, value_name = "JSON", required_unless_present = "file")] + /// Variant ID to include. Repeat for multiple items; append `=QUANTITY` to set a quantity. + #[arg(long, value_name = "VARIANT_ID[=QUANTITY]")] + item: Vec, + + /// Deliberately replace the cart with no items. + #[arg(long)] + clear_items: bool, + + /// Preferred ISO 4217 currency for checkout prices (for example, USD or GBP). + #[arg(long, value_name = "CODE", value_parser = currency_code)] + currency: Option, + + /// Buyer's first name. + #[arg(long, value_name = "NAME")] + buyer_first_name: Option, + + /// Buyer's last name. + #[arg(long, value_name = "NAME")] + buyer_last_name: Option, + + /// Buyer's email address. + #[arg(long, value_name = "EMAIL")] + buyer_email: Option, + + /// Buyer's phone number. + #[arg(long, value_name = "PHONE")] + buyer_phone: Option, + + /// Select one saved payment instrument by ID. + #[arg(long, value_name = "INSTRUMENT_ID")] + payment_instrument: Option, + + /// Full checkout replacement as raw JSON for advanced Shopping API fields. + #[arg(long, value_name = "JSON")] body: Option, /// Path to a JSON checkout replacement. Takes precedence over --body. @@ -20,26 +57,43 @@ struct Args { pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("update", "Fully replace a Shopping checkout") + CommandSpec::from_args::("update", "Optionally update a Shopping checkout") .with_long( - "Fully replace a checkout with a Shopping API request object supplied through \ - --body or --file. The request must include line_items; use an empty array only to \ - deliberately clear the cart. `gddy guide shopping` documents the request format.", + "Optionally replace an open checkout before completion. Use --item and optional \ + buyer/payment flags for common changes, or --clear-items to deliberately empty the \ + cart. Updates replace checkout state; use --body or --file for advanced fields.", ) .with_system("shopping") .with_tier(Tier::Mutate) .mutates(true) .handles_dry_run(true) .with_scopes(SHOPPING_SCOPES) - .auth_optional(), + .auth_optional() + .with_view_id(HUMAN_VIEW_ID), |ctx, args: Args| async move { - let body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + let input = CheckoutInput { + items: args.item, + clear_items: args.clear_items, + currency: args.currency, + buyer_first_name: args.buyer_first_name, + buyer_last_name: args.buyer_last_name, + buyer_email: args.buyer_email, + buyer_phone: args.buyer_phone, + payment_instrument: args.payment_instrument, + }; + reject_mixed_checkout_input(args.body.as_deref(), args.file.as_deref(), input.is_present())?; + let body = if args.body.is_some() || args.file.is_some() { + read_json(args.body.as_deref(), args.file.as_deref(), "object")? + } else { + input.update_body()? + }; if has_conflicting_checkout_id(&body, &args.id) { return Err(crate::error::GddyError::validation( "checkout ID in request body conflicts with CHECKOUT_ID", ) .into_cli_error()); } + reject_multiple_payment_instruments(&body)?; if ctx.dry_run() { return Ok(CommandResult::new(serde_json::json!({ "action": "dry-run: would update checkout", @@ -48,12 +102,24 @@ pub(super) fn command() -> RuntimeCommandSpec { }))); } let client = make_client(&ctx).await?; - Ok(CommandResult::new( - client - .update_checkout(&args.id, body) - .await - .map_err(client_err)?, - )) + let checkout = client + .update_checkout(&args.id, body) + .await + .map_err(client_err)?; + let env = crate::environments::resolve(&ctx.middleware.env)?; + let actions = no_saved_payment_method_action( + &checkout, + &ctx.middleware.env, + &env.account_url, + ) + .into_iter() + .collect::>(); + let output = if ctx.middleware.output_format == "human" { + crate::shopping::checkout::get::human_response(&checkout, &actions) + } else { + checkout + }; + Ok(CommandResult::new(output).with_next_actions(actions)) }, ) } diff --git a/rust/src/shopping/common.rs b/rust/src/shopping/common.rs index 82210d30..2218ad5d 100644 --- a/rust/src/shopping/common.rs +++ b/rust/src/shopping/common.rs @@ -1,9 +1,10 @@ use std::time::{Duration, Instant}; use cli_engine::{CliCoreError, CommandContext, Result}; -use serde_json::Value; +use serde_json::{Map, Value, json}; use crate::error::GddyError; +use crate::next_action::next_action; use crate::shopping::SHOPPING_SCOPES; use crate::shopping::client::{ClientError, ShoppingClient}; @@ -59,6 +60,171 @@ pub(crate) fn has_conflicting_checkout_id(body: &Value, id: &str) -> bool { .any(|body_id| body_id != id) } +#[derive(Debug, Clone, Default)] +pub(crate) struct CheckoutInput { + pub(crate) items: Vec, + pub(crate) clear_items: bool, + pub(crate) currency: Option, + pub(crate) buyer_first_name: Option, + pub(crate) buyer_last_name: Option, + pub(crate) buyer_email: Option, + pub(crate) buyer_phone: Option, + pub(crate) payment_instrument: Option, +} + +impl CheckoutInput { + pub(crate) fn is_present(&self) -> bool { + !self.items.is_empty() + || self.clear_items + || self.currency.is_some() + || self.buyer_first_name.is_some() + || self.buyer_last_name.is_some() + || self.buyer_email.is_some() + || self.buyer_phone.is_some() + || self.payment_instrument.is_some() + } + + pub(crate) fn create_body(&self) -> Result { + if self.items.is_empty() { + return Err(GddyError::validation( + "checkout create requires at least one --item or a JSON request through --body or --file", + ) + .into_cli_error()); + } + self.body(false) + } + + pub(crate) fn update_body(&self) -> Result { + if self.clear_items && !self.items.is_empty() { + return Err( + GddyError::validation("--clear-items cannot be combined with --item") + .into_cli_error(), + ); + } + if self.items.is_empty() && !self.clear_items { + return Err(GddyError::validation( + "checkout update requires at least one --item or --clear-items when not using --body or --file", + ) + .into_cli_error()); + } + self.body(true) + } + + pub(crate) fn completion_body(&self, idempotency_key: Option<&str>) -> Result { + if !self.items.is_empty() + || self.clear_items + || self.currency.is_some() + || self.buyer_first_name.is_some() + || self.buyer_last_name.is_some() + || self.buyer_email.is_some() + || self.buyer_phone.is_some() + { + return Err(GddyError::validation( + "checkout complete only supports --payment-instrument and --idempotency-key in structured mode", + ) + .into_cli_error()); + } + let payment_instrument = nonblank_value( + self.payment_instrument.as_deref(), + "--payment-instrument must be non-empty", + )?; + let mut body = json!({ + "payment": {"instruments": [{"id": payment_instrument, "selected": true}]} + }); + if let Some(idempotency_key) = idempotency_key { + body.as_object_mut() + .expect("completion body is an object") + .insert("idempotency_key".to_owned(), json!(idempotency_key)); + } + Ok(body) + } + + fn body(&self, allow_empty_items: bool) -> Result { + let mut body = Map::new(); + if !self.items.is_empty() { + body.insert("line_items".to_owned(), line_items(&self.items)?); + } else if allow_empty_items && self.clear_items { + body.insert("line_items".to_owned(), json!([])); + } + if let Some(currency) = &self.currency { + body.insert("context".to_owned(), json!({"currency": currency})); + } + let mut buyer = Map::new(); + insert_optional_nonblank(&mut buyer, "first_name", self.buyer_first_name.as_deref())?; + insert_optional_nonblank(&mut buyer, "last_name", self.buyer_last_name.as_deref())?; + insert_optional_nonblank(&mut buyer, "email", self.buyer_email.as_deref())?; + insert_optional_nonblank(&mut buyer, "phone_number", self.buyer_phone.as_deref())?; + if !buyer.is_empty() { + body.insert("buyer".to_owned(), Value::Object(buyer)); + } + if let Some(payment_instrument) = &self.payment_instrument { + body.insert( + "payment".to_owned(), + json!({"instruments": [{"id": nonblank_value(Some(payment_instrument), "--payment-instrument must be non-empty")?, "selected": true}]}), + ); + } + Ok(Value::Object(body)) + } +} + +fn line_items(items: &[String]) -> Result { + items + .iter() + .map(|item| { + let (id, quantity) = parse_item(item)?; + Ok(json!({"item": {"id": id}, "quantity": quantity})) + }) + .collect::>>() + .map(Value::Array) +} + +fn parse_item(value: &str) -> Result<(&str, u64)> { + let value = value.trim(); + let (id, quantity) = match value.rsplit_once('=') { + Some((id, quantity)) => { + let quantity = quantity.parse::().map_err(|_| { + GddyError::validation(format!( + "invalid --item {value:?}: quantity after '=' must be a positive integer" + )) + .into_cli_error() + })?; + (id, quantity) + } + None => (value, 1), + }; + if id.trim().is_empty() || quantity == 0 { + return Err(GddyError::validation(format!( + "invalid --item {value:?}: item ID must be non-empty and quantity must be positive" + )) + .into_cli_error()); + } + Ok((id.trim(), quantity)) +} + +fn nonblank_value<'a>(value: Option<&'a str>, error: &str) -> Result<&'a str> { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| GddyError::validation(error).into_cli_error()) +} + +fn insert_optional_nonblank( + object: &mut Map, + key: &str, + value: Option<&str>, +) -> Result<()> { + if let Some(value) = value { + object.insert( + key.to_owned(), + json!(nonblank_value( + Some(value), + &format!("--buyer-{key} must be non-empty") + )?), + ); + } + Ok(()) +} + pub(crate) fn currency_code(value: &str) -> std::result::Result { let normalized = value.trim().to_ascii_uppercase(); if normalized.len() == 3 @@ -97,19 +263,32 @@ pub(crate) fn merge_context_currency(request: &mut Value, currency: Option<&str> Ok(()) } +pub(crate) fn reject_multiple_payment_instruments(body: &Value) -> Result<()> { + let Some(instruments) = body.pointer("/payment/instruments") else { + return Ok(()); + }; + let instruments = instruments.as_array().ok_or_else(|| { + GddyError::validation("payment.instruments must be a JSON array").into_cli_error() + })?; + if instruments.len() > 1 { + return Err(GddyError::validation( + "only one payment instrument may be specified for a Shopping checkout", + ) + .with_fix( + "Specify one saved payment instrument, or omit payment until checkout completion.", + ) + .into_cli_error()); + } + Ok(()) +} + pub(crate) fn require_selected_payment_instrument(body: &Value) -> Result<()> { - let selected = body + reject_multiple_payment_instruments(body)?; + let instruments = body .pointer("/payment/instruments") - .and_then(Value::as_array) - .map(|instruments| { - instruments - .iter() - .filter(|instrument| { - instrument.get("selected").and_then(Value::as_bool) == Some(true) - }) - .collect::>() - }); - if let Some([instrument]) = selected.as_deref() + .and_then(Value::as_array); + if let Some([instrument]) = instruments.map(Vec::as_slice) + && instrument.get("selected").and_then(Value::as_bool) == Some(true) && instrument .get("id") .and_then(Value::as_str) @@ -120,13 +299,50 @@ pub(crate) fn require_selected_payment_instrument(body: &Value) -> Result<()> { Err(GddyError::validation( "checkout completion requires exactly one selected saved payment instrument with an ID", ) - .with_fix( - "Include payment.instruments with exactly one saved instrument ID marked selected: true.", + .with_fix("Include payment.instruments with one saved instrument ID marked selected: true.") + .into_cli_error()) + } +} + +pub(crate) fn reject_mixed_checkout_input( + body: Option<&str>, + file: Option<&str>, + structured_input: bool, +) -> Result<()> { + if structured_input && (body.is_some() || file.is_some()) { + Err(GddyError::validation( + "use either checkout flags or a JSON request through --body or --file, not both", ) .into_cli_error()) + } else { + Ok(()) } } +pub(crate) fn no_saved_payment_method_action( + checkout: &Value, + env: &str, + account_url: &str, +) -> Option { + checkout + .pointer("/payment/instruments") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) + .then(|| { + let command = if matches!(env, "prod" | "production") { + "payment-methods add".to_owned() + } else { + format!("--env {env} payment-methods add") + }; + next_action( + command, + format!( + "No saved payment method is available. Add one at {account_url}/payment-methods/add-payment, then retrieve this checkout again." + ), + ) + }) +} + /// Returns a supplied non-empty key, or inserts a new UUID for this one request. pub(crate) fn ensure_completion_idempotency_key(body: &mut Value) -> Result { let object = body @@ -246,7 +462,148 @@ mod tests { } #[test] - fn requires_exactly_one_selected_payment_instrument() { + fn builds_structured_checkout_body() { + let body = CheckoutInput { + items: vec!["product-a=2".to_owned(), "product-b".to_owned()], + currency: Some("GBP".to_owned()), + buyer_first_name: Some("Jane".to_owned()), + buyer_email: Some("jane@example.test".to_owned()), + payment_instrument: Some("payment-1".to_owned()), + ..CheckoutInput::default() + } + .create_body() + .expect("structured input should build"); + + assert_eq!( + body, + json!({ + "line_items": [ + {"item": {"id": "product-a"}, "quantity": 2}, + {"item": {"id": "product-b"}, "quantity": 1} + ], + "context": {"currency": "GBP"}, + "buyer": {"first_name": "Jane", "email": "jane@example.test"}, + "payment": {"instruments": [{"id": "payment-1", "selected": true}]} + }) + ); + } + + #[test] + fn rejects_invalid_structured_checkout_items() { + assert!( + CheckoutInput { + items: vec!["product=0".to_owned()], + ..CheckoutInput::default() + } + .create_body() + .is_err() + ); + assert!( + CheckoutInput { + items: vec!["product=two".to_owned()], + ..CheckoutInput::default() + } + .create_body() + .is_err() + ); + } + + #[test] + fn update_requires_cart_intent_and_supports_clear_items() { + assert!( + CheckoutInput { + buyer_email: Some("jane@example.test".to_owned()), + ..CheckoutInput::default() + } + .update_body() + .is_err() + ); + assert_eq!( + CheckoutInput { + clear_items: true, + ..CheckoutInput::default() + } + .update_body() + .expect("clear-items should be valid"), + json!({"line_items": []}) + ); + assert!( + CheckoutInput { + clear_items: true, + items: vec!["product".to_owned()], + ..CheckoutInput::default() + } + .update_body() + .is_err() + ); + } + + #[test] + fn builds_structured_completion_with_one_payment_instrument() { + let body = CheckoutInput { + payment_instrument: Some("payment-1".to_owned()), + ..CheckoutInput::default() + } + .completion_body(Some("customer-key")) + .expect("completion body should build"); + + assert_eq!( + body, + json!({ + "payment": {"instruments": [{"id": "payment-1", "selected": true}]}, + "idempotency_key": "customer-key" + }) + ); + } + + #[test] + fn rejects_mixed_checkout_input_sources() { + assert!(reject_mixed_checkout_input(Some("{}"), None, true).is_err()); + assert!(reject_mixed_checkout_input(None, Some("request.json"), true).is_err()); + assert!(reject_mixed_checkout_input(Some("{}"), Some("request.json"), false).is_ok()); + } + + #[test] + fn adds_payment_method_actions_for_resolved_environment_urls() { + let empty_instruments = json!({"payment": {"instruments": []}}); + for (env, account_url, command) in [ + ( + "prod", + "https://account.godaddy.com", + "gddy payment-methods add", + ), + ( + "test", + "https://account.test-godaddy.com", + "gddy --env test payment-methods add", + ), + ( + "dev", + "https://account.dev-godaddy.com", + "gddy --env dev payment-methods add", + ), + ] { + let action = no_saved_payment_method_action(&empty_instruments, env, account_url) + .expect("empty list should require a payment method"); + assert_eq!(action.command, command); + assert!( + action + .description + .contains(&format!("{account_url}/payment-methods/add-payment")) + ); + } + assert!( + no_saved_payment_method_action( + &json!({"payment": {}}), + "prod", + "https://account.godaddy.com", + ) + .is_none() + ); + } + + #[test] + fn requires_exactly_one_payment_instrument() { assert!( require_selected_payment_instrument(&json!({ "payment": {"instruments": [{"id": "payment-1", "selected": true}]} @@ -261,7 +618,10 @@ mod tests { ); assert!( require_selected_payment_instrument(&json!({ - "payment": {"instruments": [{"selected": true}, {"selected": true}]} + "payment": {"instruments": [ + {"id": "payment-1", "selected": true}, + {"id": "payment-2", "selected": false} + ]} })) .is_err() ); diff --git a/rust/src/shopping/guides/shopping.md b/rust/src/shopping/guides/shopping.md index 738c1cdd..2713fc8e 100644 --- a/rust/src/shopping/guides/shopping.md +++ b/rust/src/shopping/guides/shopping.md @@ -37,9 +37,9 @@ gddy --env shopping catalog search --body '{}' ## Request documents and output Use `--body` for a small inline JSON request or `--file` for a reusable JSON document. -`--file` takes precedence over `--body`. The Shopping API uses nested checkout objects, -so checkout create, update, and complete requests remain JSON documents instead of a -long list of CLI flags. +`--file` takes precedence over `--body`. For common checkout workflows, use the checkout +flags below; use JSON for advanced nested API fields. Do not combine checkout flags with +`--body` or `--file` in the same command. JSON is the default output format. For successful non-dry-run requests, the full Shopping API response is in the envelope's `data` field. Add `--output human` for a @@ -96,12 +96,33 @@ is not required before completion when the checkout is already ready. Include bu and other supported checkout information when creating a checkout that is ready to complete. ```bash -gddy shopping checkout create --body '{ - "context": {"currency": "USD"}, +gddy shopping checkout create \ + --item '' \ + --currency USD \ + --buyer-first-name Jane \ + --buyer-last-name Doe \ + --buyer-email jane.doe@example.test \ + --buyer-phone '+15550100' +``` + +Repeat `--item` to create a cart; append `=QUANTITY` to an item, such as +`--item '=2'`. Add `--payment-instrument ` +to select one stored payment method. It is optional at creation: a ready checkout can expose a +saved instrument for selection at completion. + +A stored payment instrument normally supplies its saved billing address automatically. Use a +JSON document when you need an address override or other advanced nested fields: + +```json +{ "line_items": [ { - "item": {"id": ""}, - "quantity": 1 + "item": {"id": ""}, + "quantity": 1, + "input": { + "type": "", + "references": {"": ""} + } } ], "buyer": { @@ -110,27 +131,37 @@ gddy shopping checkout create --body '{ "email": "jane.doe@example.test", "phone_number": "+15550100" }, + "context": { + "currency": "USD" + }, "payment": { - "instruments": [ - { - "id": "", - "selected": true, - "billing_address": { - "street_address": "123 Example Street", - "address_locality": "Exampleville", - "address_region": "CA", - "postal_code": "94043", - "address_country": "US" - } + "instruments": [{ + "id": "", + "selected": true, + "billing_address": { + "street_address": "123 Example Street", + "extended_address": "Suite 200", + "address_locality": "Exampleville", + "address_region": "CA", + "postal_code": "94043", + "address_country": "US", + "first_name": "Jane", + "last_name": "Doe", + "phone_number": "+15550100" } - ] + }] } -}' +} ``` -`billing_address` on the selected saved payment instrument is optional. You can provide it -when creating the checkout or in the completion request; omitting it retains the saved -instrument's existing billing address. +`line_items` is the only required top-level field for create or update. Each line item requires +an `item.id` (a catalog variant ID) and a positive integer `quantity`. Include `input` only when +the selected variant's published input schema requires it. `buyer`, `context`, `signals`, +`attribution`, `payment`, and `fulfillment` are optional. Do not send response-owned fields such +as checkout `id`, `status`, `totals`, `currency`, `messages`, `order`, or `ucp`. + +Only one payment instrument may be specified for checkout create, update, or complete. Use +`--file checkout.json` rather than placing address information in shell history. Use `checkout get ` when you need to inspect an existing open checkout or recover its available payment instruments. Its human output shows checkout status, items, @@ -139,18 +170,34 @@ totals, and the selected masked payment method. Use `--output json` for the full ## Optionally update an open checkout `checkout update` is optional. Use it only to change an existing checkout. It replaces the -checkout with the supplied document, so include every line item and all retained fields. An empty -`line_items` array deliberately clears the cart. +checkout state, so structured updates must include every desired cart item. Use `--clear-items` +only to deliberately empty the cart. ```bash -gddy shopping checkout update --file update-checkout.json +gddy shopping checkout update \ + --item '=2' \ + --buyer-email jane.doe@example.test ``` +Use `--file update-checkout.json` for advanced replacement fields, such as fulfillment, product +input, attribution, signals, or a billing-address override. + ## Complete a checkout -`checkout complete` places a real order. Provide exactly one selected saved payment -instrument. You can supply a non-empty `idempotency_key`, or omit it to let gddy generate -one and return it in human output. Preserve the effective key for lost-response recovery. +`checkout complete` places a real order. Select exactly one saved payment instrument. The +common form is: + +```bash +gddy shopping checkout complete \ + --payment-instrument +``` + +Use `--idempotency-key ` to supply a non-empty key, or omit it to let +gddy generate one and return it in human output. Preserve the effective key for lost-response +recovery. + +Use `--file complete-checkout.json` for an optional billing-address override or another +advanced payment field. The JSON may specify only one payment instrument: ```json { @@ -173,11 +220,6 @@ one and return it in human output. Preserve the effective key for lost-response } ``` -```bash -gddy shopping checkout complete \ - --file complete-checkout.json -``` - Completion returns immediately after the single purchase attempt. A successful response includes the order ID and a **View order** permalink for the customer's account. It never automatically retries. If the result is uncertain, first confirm the outcome; only then reuse the same From a7157f881a5233860d4412a0d1518e6be1cb811b Mon Sep 17 00:00:00 2001 From: sswaminathan Date: Thu, 10 Sep 2026 11:55:05 -0700 Subject: [PATCH 11/14] feat(shopping): simplify catalog discovery Co-Authored-By: Claude --- rust/src/shopping/catalog/get.rs | 39 +++- rust/src/shopping/catalog/lookup.rs | 40 +++- rust/src/shopping/catalog/search.rs | 321 +++++++++++++++++++++++---- rust/src/shopping/common.rs | 13 +- rust/src/shopping/guides/shopping.md | 55 +++-- rust/src/shopping/money.rs | 40 ++-- 6 files changed, 411 insertions(+), 97 deletions(-) diff --git a/rust/src/shopping/catalog/get.rs b/rust/src/shopping/catalog/get.rs index fb7e1935..c8b8e34e 100644 --- a/rust/src/shopping/catalog/get.rs +++ b/rust/src/shopping/catalog/get.rs @@ -16,17 +16,21 @@ output_schema!(CatalogProductOutput { #[derive(Debug, Clone, clap::Args)] struct Args { - /// Product request as raw JSON, including `id`. - #[arg(long, value_name = "JSON", required_unless_present = "file")] + /// Product or variant ID to retrieve. + #[arg(long, value_name = "ID", required_unless_present_any = ["body", "file"])] + id: Option, + + /// Preferred ISO 4217 currency for returned catalog prices (for example, USD or GBP). + #[arg(long, value_name = "CODE", value_parser = currency_code)] + currency: Option, + + /// Product request as raw JSON for advanced Shopping API selections and preferences. + #[arg(long, value_name = "JSON")] body: Option, /// Path to a JSON product request. Takes precedence over --body. #[arg(long, value_name = "PATH")] file: Option, - - /// Preferred ISO 4217 currency for returned catalog prices (for example, USD or GBP). - #[arg(long, value_name = "CODE", value_parser = currency_code)] - currency: Option, } const HUMAN_VIEW_ID: &str = "shopping-catalog-get"; @@ -40,14 +44,33 @@ pub(crate) fn register_human_view(ctx: &mut ModuleContext<'_>) { pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("get", "Get one Shopping catalog product") - .with_long("Submit a UCP product JSON object containing `id`.") + .with_long( + "Get one product or variant with --id. Use --body or --file only for advanced \ + Shopping API selections and preferences.", + ) .with_system("shopping") .with_tier(Tier::Read) .with_scopes(SHOPPING_SCOPES) .with_output_schema::() .with_view_id(HUMAN_VIEW_ID), |ctx, args: Args| async move { - let mut body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + let mut body = if args.body.is_some() || args.file.is_some() { + read_json(args.body.as_deref(), args.file.as_deref(), "object")? + } else { + json!({}) + }; + if let Some(id) = args.id { + let object = body + .as_object_mut() + .expect("catalog product request is an object"); + if object.contains_key("id") { + return Err(crate::error::GddyError::validation( + "--id conflicts with id in the request body", + ) + .into_cli_error()); + } + object.insert("id".to_owned(), json!(id)); + } merge_context_currency(&mut body, args.currency.as_deref())?; let client = make_client(&ctx).await?; let response = client.catalog_product(body).await.map_err(client_err)?; diff --git a/rust/src/shopping/catalog/lookup.rs b/rust/src/shopping/catalog/lookup.rs index 8f284cc3..41ff0d13 100644 --- a/rust/src/shopping/catalog/lookup.rs +++ b/rust/src/shopping/catalog/lookup.rs @@ -1,4 +1,5 @@ use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use serde_json::json; use crate::output_schema::output_schema; use crate::shopping::SHOPPING_SCOPES; @@ -14,32 +15,53 @@ output_schema!(CatalogLookupOutput { #[derive(Debug, Clone, clap::Args)] struct Args { - /// Lookup request as raw JSON, including the `ids` array. - #[arg(long, value_name = "JSON", required_unless_present = "file")] + /// Product or variant ID to resolve. Repeat to resolve multiple IDs. + #[arg(long, value_name = "ID", required_unless_present_any = ["body", "file"])] + id: Vec, + + /// Preferred ISO 4217 currency for returned catalog prices (for example, USD or GBP). + #[arg(long, value_name = "CODE", value_parser = currency_code)] + currency: Option, + + /// Lookup request as raw JSON for advanced Shopping API filters and extensions. + #[arg(long, value_name = "JSON")] body: Option, /// Path to a JSON lookup request. Takes precedence over --body. #[arg(long, value_name = "PATH")] file: Option, - - /// Preferred ISO 4217 currency for returned catalog prices (for example, USD or GBP). - #[arg(long, value_name = "CODE", value_parser = currency_code)] - currency: Option, } pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("lookup", "Resolve known Shopping catalog IDs") .with_long( - "Submit a UCP catalog-lookup JSON object containing one or more `ids`. Unknown IDs \ - are reported in the response messages rather than failing the whole request.", + "Resolve one or more known product or variant IDs with repeatable --id. Unknown IDs \ + are reported in the response messages rather than failing the whole request. Use \ + --body or --file only for advanced Shopping API fields.", ) .with_system("shopping") .with_tier(Tier::Read) .with_scopes(SHOPPING_SCOPES) .with_output_schema::(), |ctx, args: Args| async move { - let mut body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + let mut body = if args.body.is_some() || args.file.is_some() { + read_json(args.body.as_deref(), args.file.as_deref(), "object")? + } else { + json!({}) + }; + if !args.id.is_empty() { + let object = body + .as_object_mut() + .expect("catalog lookup request is an object"); + if object.contains_key("ids") { + return Err(crate::error::GddyError::validation( + "--id conflicts with ids in the request body", + ) + .into_cli_error()); + } + object.insert("ids".to_owned(), json!(args.id)); + } merge_context_currency(&mut body, args.currency.as_deref())?; let client = make_client(&ctx).await?; Ok(CommandResult::new( diff --git a/rust/src/shopping/catalog/search.rs b/rust/src/shopping/catalog/search.rs index e257c790..445047f3 100644 --- a/rust/src/shopping/catalog/search.rs +++ b/rust/src/shopping/catalog/search.rs @@ -17,33 +17,48 @@ output_schema!(CatalogSearchOutput { #[derive(Debug, Clone, clap::Args)] struct Args { - /// Search request as raw JSON. Use `{}` to browse all products. - #[arg(long, value_name = "JSON", required_unless_present = "file")] - body: Option, + /// Text to search for. Omit to browse the catalog. + #[arg(long, value_name = "TEXT")] + query: Option, - /// Path to a JSON search request. Takes precedence over --body. - #[arg(long, value_name = "PATH")] - file: Option, + /// Product category to include. Repeat to include multiple categories. + #[arg(long, value_name = "CATEGORY")] + category: Vec, - /// Preferred ISO 4217 currency for returned catalog prices (for example, USD or GBP). - #[arg(long, value_name = "CODE", value_parser = currency_code)] - currency: Option, + /// Opaque cursor from the preceding catalog-search response. + #[arg(long, value_name = "CURSOR")] + cursor: Option, /// Maximum number of products to return (1-100). #[arg(long, value_name = "N", value_parser = clap::value_parser!(u8).range(1..=100))] limit: Option, + + /// Preferred ISO 4217 currency for returned catalog prices (for example, USD or GBP). + #[arg(long, value_name = "CODE", value_parser = currency_code)] + currency: Option, + + /// Buyer country used for catalog eligibility and pricing. + #[arg(long, value_name = "ISO_COUNTRY_CODE")] + country: Option, + + /// Search request as raw JSON for advanced Shopping API filters and extensions. + #[arg(long, value_name = "JSON")] + body: Option, + + /// Path to a JSON search request. Takes precedence over --body. + #[arg(long, value_name = "PATH")] + file: Option, } pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("search", "Search the Shopping catalog") .with_long( - "Search the Shopping catalog. Human output groups purchasable variants under each \ - product. Use --output json to receive the unmodified Shopping API response. Supply the \ - complete UCP search request with --body or --file; use `{}` to browse all products. \ - Place `pagination.limit` and the response cursor in the request body to retrieve later \ - pages with the same search criteria. Use `gddy shopping catalog get` for a selected \ - product's complete record.", + "Search the Shopping catalog. Omit filters to browse all products. Use --query, \ + repeatable --category, --cursor, --limit, --currency, and --country for common \ + search criteria. Human output groups purchasable variants under each product; \ + --output json returns the unmodified Shopping API response. Use --body or --file \ + only for advanced API filters and extensions.", ) .with_system("shopping") .with_tier(Tier::Read) @@ -51,11 +66,26 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_output_schema::() .with_view_id(HUMAN_VIEW_ID), |ctx, args: Args| async move { - let mut request = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; + let mut request = if args.body.is_some() || args.file.is_some() { + read_json(args.body.as_deref(), args.file.as_deref(), "object")? + } else { + json!({}) + }; + merge_search_args( + &mut request, + args.query.as_deref(), + &args.category, + args.cursor.as_deref(), + args.country.as_deref(), + )?; merge_context_currency(&mut request, args.currency.as_deref())?; merge_pagination(&mut request, args.limit)?; + validate_price_filter_currency(&request)?; let client = make_client(&ctx).await?; - let response = client.catalog_search(request.clone()).await.map_err(client_err)?; + let response = client + .catalog_search(request.clone()) + .await + .map_err(client_err)?; let next_actions = next_actions(&response, &mut request, &ctx.middleware.env)?; let output = if ctx.middleware.output_format == "human" { human_response(&response, &next_actions) @@ -109,6 +139,7 @@ fn human_response(response: &Value, actions: &[cli_engine::NextAction]) -> Value "category": category(product), "price": money(variant.get("price")), "list_price": money(variant.get("list_price")), + "term": term(variant), "availability": availability(variant), })) .collect::>(), @@ -124,6 +155,82 @@ fn human_response(response: &Value, actions: &[cli_engine::NextAction]) -> Value }) } +fn merge_search_args( + request: &mut Value, + query: Option<&str>, + categories: &[String], + cursor: Option<&str>, + country: Option<&str>, +) -> Result<()> { + let object = request + .as_object_mut() + .expect("catalog search request is an object"); + if let Some(query) = query { + merge_string(object, "query", query, "--query")?; + } + if !categories.is_empty() { + let filters = object.entry("filters").or_insert_with(|| json!({})); + let filters = filters.as_object_mut().ok_or_else(|| { + crate::error::GddyError::validation("filters must be a JSON object").into_cli_error() + })?; + if filters.contains_key("categories") { + return Err(crate::error::GddyError::validation( + "--category conflicts with filters.categories in the request body", + ) + .into_cli_error()); + } + filters.insert("categories".to_owned(), json!(categories)); + } + if let Some(cursor) = cursor { + let pagination = object.entry("pagination").or_insert_with(|| json!({})); + let pagination = pagination.as_object_mut().ok_or_else(|| { + crate::error::GddyError::validation("pagination must be a JSON object").into_cli_error() + })?; + merge_string(pagination, "cursor", cursor, "--cursor")?; + } + if let Some(country) = country { + let context = object.entry("context").or_insert_with(|| json!({})); + let context = context.as_object_mut().ok_or_else(|| { + crate::error::GddyError::validation("context must be a JSON object").into_cli_error() + })?; + merge_string(context, "address_country", country, "--country")?; + } + Ok(()) +} + +fn merge_string( + object: &mut serde_json::Map, + key: &str, + value: &str, + flag: &str, +) -> Result<()> { + if let Some(existing) = object.get(key).and_then(Value::as_str) + && existing != value + { + return Err(crate::error::GddyError::validation(format!( + "{flag} conflicts with {key} in the request body" + )) + .into_cli_error()); + } + object.insert(key.to_owned(), json!(value)); + Ok(()) +} + +fn validate_price_filter_currency(request: &Value) -> Result<()> { + if request.pointer("/filters/price").is_some() + && request + .pointer("/context/currency") + .and_then(Value::as_str) + .is_none() + { + return Err(crate::error::GddyError::validation( + "filters.price requires context.currency because price bounds are currency-specific minor units", + ) + .into_cli_error()); + } + Ok(()) +} + fn merge_pagination(request: &mut Value, limit: Option) -> Result<()> { if limit.is_none() { return Ok(()); @@ -171,13 +278,13 @@ fn product_actions(response: &Value, request: &Value, env: &str) -> Vec) -> Result { + if is_simple_search_request(request) { + let mut command = "catalog search".to_owned(); + append_search_flag(&mut command, "query", request.get("query")); + let filters = request.get("filters").and_then(Value::as_object); + if let Some(categories) = filters + .and_then(|filters| filters.get("categories")) + .and_then(Value::as_array) + { + for category in categories.iter().filter_map(Value::as_str) { + command.push_str(&format!(" --category '{category}'")); + } + } + let pagination = request.get("pagination").and_then(Value::as_object); + append_search_flag( + &mut command, + "cursor", + pagination.and_then(|pagination| pagination.get("cursor")), + ); + if let Some(limit) = pagination + .and_then(|pagination| pagination.get("limit")) + .and_then(Value::as_u64) + { + command.push_str(&format!(" --limit {limit}")); + } + let context = request.get("context").and_then(Value::as_object); + append_search_flag( + &mut command, + "currency", + context.and_then(|context| context.get("currency")), + ); + append_search_flag( + &mut command, + "country", + context.and_then(|context| context.get("address_country")), + ); + Ok(command) + } else { + let encoded_request = serde_json::to_string(request).map_err(|error| { + crate::error::GddyError::unexpected(format!( + "failed to encode next-page request: {error}" + )) + .into_cli_error() + })?; + Ok(format!("catalog search --body '{encoded_request}'")) + } +} + +fn append_search_flag(command: &mut String, name: &str, value: Option<&Value>) { + if let Some(value) = value.and_then(Value::as_str) { + command.push_str(&format!(" --{name} '{value}'")); + } +} + +fn is_simple_search_request(request: &serde_json::Map) -> bool { + request + .keys() + .all(|key| matches!(key.as_str(), "query" | "filters" | "pagination" | "context")) + && request + .get("filters") + .and_then(Value::as_object) + .is_none_or(|filters| filters.keys().all(|key| key == "categories")) + && request + .get("pagination") + .and_then(Value::as_object) + .is_none_or(|pagination| { + pagination + .keys() + .all(|key| key == "cursor" || key == "limit") + }) + && request + .get("context") + .and_then(Value::as_object) + .is_none_or(|context| { + context + .keys() + .all(|key| key == "currency" || key == "address_country") + }) +} + fn render_human(response: &Value) -> String { let mut output = response .get("summary") @@ -306,6 +490,21 @@ fn render_human(response: &Value) -> String { output } +fn term(variant: &Value) -> String { + variant + .get("options") + .and_then(Value::as_array) + .and_then(|options| { + options + .iter() + .find(|option| option.get("name").and_then(Value::as_str) == Some("Term")) + }) + .and_then(|option| option.get("label")) + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_default() +} + fn render_variants_table(product: &Value) -> String { let rows = product .get("variants") @@ -339,6 +538,11 @@ fn render_variants_table(product: &Value) -> String { .and_then(Value::as_str) .unwrap_or_default() .to_owned(), + variant + .get("term") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), variant .get("availability") .and_then(Value::as_str) @@ -354,10 +558,11 @@ fn render_variants_table(product: &Value) -> String { "Category", "Your Price", "List Price", + "Term", "Availability", ], &rows, - &[false, false, false, true, true, false], + &[false, false, false, true, true, false, false], ) } @@ -452,7 +657,10 @@ fn money(value: Option<&Value>) -> Option { mod tests { use serde_json::json; - use super::{human_response, merge_pagination, next_actions, render_human}; + use super::{ + human_response, merge_pagination, merge_search_args, next_actions, render_human, + validate_price_filter_currency, + }; use crate::shopping::command_for_env; use crate::shopping::common::{currency_code, merge_context_currency}; @@ -475,6 +683,45 @@ mod tests { }) } + #[test] + fn builds_search_request_without_a_json_body() { + let mut request = json!({}); + merge_search_args( + &mut request, + Some("email"), + &["email".to_owned(), "hosting".to_owned()], + Some("cursor-1"), + Some("GB"), + ) + .expect("flags should merge"); + merge_context_currency(&mut request, Some("GBP")).expect("currency should merge"); + merge_pagination(&mut request, Some(3)).expect("limit should merge"); + + assert_eq!( + request, + json!({ + "query": "email", + "filters": {"categories": ["email", "hosting"]}, + "pagination": {"cursor": "cursor-1", "limit": 3}, + "context": {"address_country": "GB", "currency": "GBP"} + }) + ); + } + + #[test] + fn requires_currency_for_raw_price_filter() { + assert!( + validate_price_filter_currency(&json!({"filters": {"price": {"min": 100}}})).is_err() + ); + assert!( + validate_price_filter_currency(&json!({ + "filters": {"price": {"min": 100}}, + "context": {"currency": "USD"} + })) + .is_ok() + ); + } + #[test] fn merges_limit_without_changing_a_body_cursor() { let mut request = json!({"query": "email", "pagination": {"cursor": "cursor-1"}}); @@ -496,13 +743,9 @@ mod tests { .iter() .all(|action| action.command.contains("gddy --env test")) ); - assert!( - actions[0] - .command - .contains("catalog get --body '{\"id\":\"product-1\"}'") - ); + assert!(actions[0].command.contains("catalog get --id 'product-1'")); assert!(actions[1].command.contains("checkout create")); - assert!(actions[2].command.contains("\"cursor\":\"next\"")); + assert!(actions[2].command.contains("--cursor 'next'")); } #[test] @@ -550,7 +793,7 @@ mod tests { assert_eq!(request.pointer("/context/currency"), Some(&json!("jpy"))); assert!(actions[1].command.contains("--currency USD")); - assert!(actions[2].command.contains("\"currency\":\"jpy\"")); + assert!(actions[2].command.contains("--currency 'jpy'")); } #[test] @@ -561,7 +804,9 @@ mod tests { #[test] fn validates_and_normalizes_currency_codes() { - assert_eq!(currency_code(" jpy ").expect("valid currency"), "JPY"); + assert_eq!(currency_code(" gbp ").expect("valid currency"), "GBP"); + assert_eq!(currency_code("jpy").expect("valid currency"), "JPY"); + assert!(currency_code("ZZZ").is_err()); assert!(currency_code("JP").is_err()); assert!(currency_code("123").is_err()); } diff --git a/rust/src/shopping/common.rs b/rust/src/shopping/common.rs index 2218ad5d..40e4fa51 100644 --- a/rust/src/shopping/common.rs +++ b/rust/src/shopping/common.rs @@ -227,15 +227,10 @@ fn insert_optional_nonblank( pub(crate) fn currency_code(value: &str) -> std::result::Result { let normalized = value.trim().to_ascii_uppercase(); - if normalized.len() == 3 - && normalized - .chars() - .all(|character| character.is_ascii_alphabetic()) - { - Ok(normalized) - } else { - Err("currency must be a three-letter ISO 4217 code".to_owned()) - } + iso_currency::Currency::from_code(&normalized) + .is_some() + .then_some(normalized) + .ok_or_else(|| "currency must be a valid ISO 4217 code".to_owned()) } pub(crate) fn merge_context_currency(request: &mut Value, currency: Option<&str>) -> Result<()> { diff --git a/rust/src/shopping/guides/shopping.md b/rust/src/shopping/guides/shopping.md index 2713fc8e..134b0557 100644 --- a/rust/src/shopping/guides/shopping.md +++ b/rust/src/shopping/guides/shopping.md @@ -1,3 +1,7 @@ +--- +summary: Browse products, place orders, and retrieve purchase details. +--- + # Shopping API `gddy shopping` integrates with the Shopping API. @@ -22,8 +26,9 @@ gddy auth login \ --scope shopping.order:read ``` -Shopping requests use the selected environment's standard API front door. Use OAuth -for Shopping commands. +Shopping requests use the selected environment's standard API front door. OAuth requests the +complete lifecycle scope bundle above in one consent flow. PAT support requires those Shopping +scopes to be available on the Developer Portal and is tracked separately. ## Environment @@ -31,7 +36,7 @@ Commands below use the active environment. To run them against another configure environment, add `--env ` before `shopping`: ```bash -gddy --env shopping catalog search --body '{}' +gddy --env shopping catalog search ``` ## Request documents and output @@ -48,46 +53,60 @@ concise terminal presentation, or `--output json` to make the default explicit. ## Discover products -Search the catalog with an optional ISO 4217 presentment-currency preference: +Search the catalog without a request body. Use `--query`, repeatable `--category`, `--cursor`, +`--limit`, `--currency`, and `--country` to refine a search: ```bash -gddy shopping catalog search --currency GBP --limit 3 --body '{}' +gddy shopping catalog search \ + --query email \ + --category email \ + --currency GBP \ + --country GB \ + --limit 3 ``` -`--currency` writes `context.currency` into the request. You can instead place it in -the JSON document. Supplying both with different values fails. The API response price -currency is authoritative; a requested currency is a preference, not a guarantee. The -current Shopping service applies the preference to catalog search; catalog lookup and -product retrieval currently accept the context but can return their default USD prices. +`--currency` writes `context.currency` into the request. The API response price currency +is authoritative; a requested currency is a preference, not a guarantee. The current +Shopping service applies the preference to catalog search; catalog lookup and product +retrieval currently accept the context but can return their default USD prices. Use +`--body` or `--file` for advanced filters and extensions. A raw `filters.price` request +must include `context.currency`, because its bounds are currency-specific minor units. `catalog search` displays products as numbered sections. Each section shows its product ID, then purchasable variants and prices. `--limit` controls **products**, not variants. Use a **product ID** with `catalog get` or `catalog lookup`; use a **variant ID** in -`checkout create` line items. +`checkout create` line items. A variant is already term-specific—select the variant whose +returned **Term** column matches the desired term. ```bash gddy shopping catalog lookup \ - --body '{"ids":["nes-wsb-vnext-tier1"]}' --currency GBP + --id nes-wsb-vnext-tier1 \ + --currency GBP gddy shopping catalog get \ - --body '{"id":"nes-wsb-vnext-tier1"}' --currency GBP + --id nes-wsb-vnext-tier1 \ + --currency GBP ``` To receive the full catalog response as valid JSON: ```bash -gddy --output json shopping catalog search --body '{}' --limit 3 +gddy --output json shopping catalog search --limit 3 ``` -Shopping API cursor pagination belongs in the request body's `pagination` object. -Preserve all original search criteria—including `context.currency`—retain the original -`pagination.limit`, and replace only the opaque `pagination.cursor`: +Use the opaque response cursor with the same search criteria: ```bash gddy shopping catalog search \ - --body '{"context":{"currency":"GBP"},"pagination":{"limit":3,"cursor":""}}' + --query email \ + --currency GBP \ + --limit 3 \ + --cursor '' ``` +For advanced filters or extensions not represented by command flags, continue using a JSON +request through `--body` or `--file`. + ## Create a checkout ready to complete Creating a checkout does not place an order. A create response includes the checkout ID, diff --git a/rust/src/shopping/money.rs b/rust/src/shopping/money.rs index 5a51e078..5239b3e0 100644 --- a/rust/src/shopping/money.rs +++ b/rust/src/shopping/money.rs @@ -1,7 +1,7 @@ use serde_json::Value; -/// Shopping currently returns integer price amounts in hundredths for every observed currency. -/// The service does not yet apply ISO 4217 currency-specific minor-unit exponents. +/// Shopping amounts are ISO-4217 minor units. Their decimal scale derives from the returned +/// currency code, not a fixed cents assumption. pub(crate) fn format_value(value: Option<&Value>) -> Option { let amount = value?.get("amount")?.as_i64()?; let currency = value?.get("currency")?.as_str()?; @@ -11,16 +11,26 @@ pub(crate) fn format_value(value: Option<&Value>) -> Option { pub(crate) fn format_amount(amount: i64, currency: &str) -> String { let sign = if amount < 0 { "-" } else { "" }; let absolute = amount.unsigned_abs(); - let whole = absolute / 100; - let fractional = absolute % 100; - let whole = grouped_integer(whole); - if fractional == 0 && uses_zero_decimal_display(currency) { + let decimals = currency_decimals(currency); + let scale = 10u64.pow(decimals); + let whole = grouped_integer(absolute / scale); + if decimals == 0 { format!("{currency} {sign}{whole}") } else { - format!("{currency} {sign}{whole}.{fractional:02}") + format!( + "{currency} {sign}{whole}.{:0width$}", + absolute % scale, + width = decimals as usize + ) } } +fn currency_decimals(currency: &str) -> u32 { + iso_currency::Currency::from_code(¤cy.to_ascii_uppercase()) + .and_then(|currency| currency.exponent()) + .map_or(2, u32::from) +} + fn grouped_integer(value: u64) -> String { let digits = value.to_string(); let first_group = digits.len() % 3; @@ -37,22 +47,22 @@ fn grouped_integer(value: u64) -> String { output } -fn uses_zero_decimal_display(currency: &str) -> bool { - matches!(currency, "JPY") -} - #[cfg(test)] mod tests { use super::*; #[test] - fn formats_observed_usd_and_jpy_amounts() { + fn formats_iso4217_minor_units() { assert_eq!(format_amount(7188, "USD"), "USD 71.88"); - assert_eq!(format_amount(1_198_800, "JPY"), "JPY 11,988"); + assert_eq!(format_amount(5988, "GBP"), "GBP 59.88"); + assert_eq!(format_amount(11_988, "JPY"), "JPY 11,988"); + assert_eq!(format_amount(1_234, "KWD"), "KWD 1.234"); + assert_eq!(format_amount(12_345_678, "CLF"), "CLF 1,234.5678"); } #[test] - fn retains_fractional_amounts_for_zero_decimal_display_currencies() { - assert_eq!(format_amount(1_198_801, "JPY"), "JPY 11,988.01"); + fn formats_negative_and_unknown_currency_amounts() { + assert_eq!(format_amount(-500, "USD"), "USD -5.00"); + assert_eq!(format_amount(1_234, "ZZZ"), "ZZZ 12.34"); } } From f4d7ce7aa6f3946130f36a28145c048569c9ebe9 Mon Sep 17 00:00:00 2001 From: sswaminathan Date: Thu, 10 Sep 2026 14:51:46 -0700 Subject: [PATCH 12/14] fix(shopping): harden output and follow-up actions - Render safe cross-platform structured next actions in human output\n- Mark checkout dry runs and preserve non-404 order-read failures\n- Encode dynamic checkout/order path segments\n- Show concise catalog lookup and bounded payment method choices\n- Render final totals only when response currency is available\n- Document the updated checkout experience Co-Authored-By: Claude --- rust/src/api_explorer/http.rs | 2 +- rust/src/api_explorer/mod.rs | 2 +- rust/src/next_action.rs | 44 ++++- rust/src/shopping/catalog/get.rs | 13 +- rust/src/shopping/catalog/lookup.rs | 197 ++++++++++++++++++- rust/src/shopping/catalog/mod.rs | 2 +- rust/src/shopping/catalog/search.rs | 253 ++++++++++++++++--------- rust/src/shopping/checkout/complete.rs | 69 ++++++- rust/src/shopping/checkout/create.rs | 21 +- rust/src/shopping/checkout/get.rs | 184 +++++++++++++----- rust/src/shopping/checkout/update.rs | 5 +- rust/src/shopping/client.rs | 33 +++- rust/src/shopping/common.rs | 79 ++++++-- rust/src/shopping/guides/shopping.md | 14 +- rust/src/shopping/mod.rs | 2 + rust/src/shopping/money.rs | 39 ++++ rust/src/shopping/order/get.rs | 43 +---- 17 files changed, 764 insertions(+), 238 deletions(-) diff --git a/rust/src/api_explorer/http.rs b/rust/src/api_explorer/http.rs index de4169b2..10005838 100644 --- a/rust/src/api_explorer/http.rs +++ b/rust/src/api_explorer/http.rs @@ -35,7 +35,7 @@ pub(super) fn split_kv(raw: &str) -> Option<(&str, &str)> { /// `{name}` placeholder in a path template. Uses `url` (already a /// dependency) rather than adding `percent-encoding` directly, since `url` /// doesn't re-export it publicly. -pub(super) fn encode_path_segment(value: &str) -> String { +pub(crate) fn encode_path_segment(value: &str) -> String { let mut url = url::Url::parse("http://x").expect("valid base URL"); url.path_segments_mut() .expect("http URL always has path segments") diff --git a/rust/src/api_explorer/mod.rs b/rust/src/api_explorer/mod.rs index 288e03c9..0b62167c 100644 --- a/rust/src/api_explorer/mod.rs +++ b/rust/src/api_explorer/mod.rs @@ -6,7 +6,7 @@ mod call; mod catalog; mod domain_cmd; mod graphql; -mod http; +pub(crate) mod http; mod operation; mod parameter; mod response; diff --git a/rust/src/next_action.rs b/rust/src/next_action.rs index 6c5682e8..c90f79ca 100644 --- a/rust/src/next_action.rs +++ b/rust/src/next_action.rs @@ -6,6 +6,7 @@ //! in the (separately versioned) `cli-engine` crate. use cli_engine::{NextAction, NextActionParam}; +use serde_json::{Value, json}; use crate::environments::APP_ID; @@ -25,9 +26,42 @@ pub(crate) fn required_value(value: impl Into) -> NextActionParam { } } +/// Mirrors cli-engine placeholder substitution for custom human views. The +/// structured template and parameters remain in the output envelope. +pub(crate) fn display_command(action: &NextAction) -> String { + action + .params + .iter() + .filter_map(|(name, param)| { + param + .value + .as_ref() + .map(|value| (format!("<{name}>"), value.as_str())) + }) + .fold(action.command.clone(), |command, (placeholder, value)| { + command.replace(&placeholder, value) + }) +} + +pub(crate) fn human_next_steps(actions: &[NextAction]) -> Value { + Value::Array( + actions + .iter() + .map(|action| { + json!({ + "command": display_command(action), + "description": action.description, + }) + }) + .collect(), + ) +} + #[cfg(test)] mod tests { - use super::required_value; + use cli_engine::NextAction; + + use super::{display_command, required_value}; #[test] fn required_value_sets_value_and_required() { @@ -35,4 +69,12 @@ mod tests { assert_eq!(param.value.as_deref(), Some("my-app")); assert!(param.required); } + + #[test] + fn display_command_substitutes_known_parameters() { + let action = NextAction::new("gddy domain get ", "Get a domain") + .with_param("domain", cli_engine::NextActionParam::value("example.com")); + + assert_eq!(display_command(&action), "gddy domain get example.com"); + } } diff --git a/rust/src/shopping/catalog/get.rs b/rust/src/shopping/catalog/get.rs index c8b8e34e..716a071c 100644 --- a/rust/src/shopping/catalog/get.rs +++ b/rust/src/shopping/catalog/get.rs @@ -1,7 +1,9 @@ -use cli_engine::{CommandResult, CommandSpec, ModuleContext, RuntimeCommandSpec, Tier}; +use cli_engine::{ + CommandResult, CommandSpec, ModuleContext, NextActionParam, RuntimeCommandSpec, Tier, +}; use serde_json::{Value, json}; -use crate::next_action::{next_action, required_value}; +use crate::next_action::{human_next_steps, next_action}; use crate::output_schema::output_schema; use crate::shopping::common::{ client_err, currency_code, make_client, merge_context_currency, read_json, @@ -118,11 +120,12 @@ fn next_actions(response: &Value, env: &str) -> Vec { next_action( command_for_env( env, - format!("checkout create --item '{variant_id}' --currency {currency}"), + "checkout create --item --currency ", ), "Create a checkout with the first available variant", ) - .with_param("variant_id", required_value(variant_id)), + .with_param("variant-id", NextActionParam::value(variant_id)) + .with_param("currency", NextActionParam::value(currency)), ] } @@ -135,7 +138,7 @@ fn human_response(response: &Value, actions: &[cli_engine::NextAction]) -> Value "categories": product.get("categories").and_then(Value::as_array).map(|categories| categories.iter().filter_map(|category| category.get("value").and_then(Value::as_str)).collect::>()).unwrap_or_default(), "price_range": product.get("price_range").cloned(), "variants": product.get("variants").cloned().unwrap_or_else(|| json!([])), - "next_steps": actions.iter().map(|action| json!({"command": action.command, "description": action.description})).collect::>(), + "next_steps": human_next_steps(actions), }) } diff --git a/rust/src/shopping/catalog/lookup.rs b/rust/src/shopping/catalog/lookup.rs index 41ff0d13..955f62b0 100644 --- a/rust/src/shopping/catalog/lookup.rs +++ b/rust/src/shopping/catalog/lookup.rs @@ -1,5 +1,5 @@ -use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; -use serde_json::json; +use cli_engine::{CommandResult, CommandSpec, ModuleContext, RuntimeCommandSpec, Tier}; +use serde_json::{Value, json}; use crate::output_schema::output_schema; use crate::shopping::SHOPPING_SCOPES; @@ -32,6 +32,14 @@ struct Args { file: Option, } +const HUMAN_VIEW_ID: &str = "shopping-catalog-lookup"; + +pub(crate) fn register_human_view(ctx: &mut ModuleContext<'_>) { + ctx.middleware_mut() + .human_views + .register_func(HUMAN_VIEW_ID, render_human); +} + pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("lookup", "Resolve known Shopping catalog IDs") @@ -43,7 +51,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("shopping") .with_tier(Tier::Read) .with_scopes(SHOPPING_SCOPES) - .with_output_schema::(), + .with_output_schema::() + .with_view_id(HUMAN_VIEW_ID), |ctx, args: Args| async move { let mut body = if args.body.is_some() || args.file.is_some() { read_json(args.body.as_deref(), args.file.as_deref(), "object")? @@ -64,9 +73,185 @@ pub(super) fn command() -> RuntimeCommandSpec { } merge_context_currency(&mut body, args.currency.as_deref())?; let client = make_client(&ctx).await?; - Ok(CommandResult::new( - client.catalog_lookup(body).await.map_err(client_err)?, - )) + let response = client.catalog_lookup(body).await.map_err(client_err)?; + let output = if ctx.middleware.output_format == "human" { + human_response(&response) + } else { + response + }; + Ok(CommandResult::new(output)) }, ) } + +fn human_response(response: &Value) -> Value { + let products = response + .get("products") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + json!({ + "products": products + .iter() + .map(|product| json!({ + "id": product.get("id").and_then(Value::as_str).unwrap_or_default(), + "title": product.get("title").and_then(Value::as_str).unwrap_or("Untitled product"), + "category": categories(product), + "price_range": price_range(product), + "variants": product + .get("variants") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(|variant| { + Some(json!({ + "id": variant.get("id").and_then(Value::as_str)?, + "title": variant.get("title").and_then(Value::as_str).unwrap_or("Untitled variant"), + "price": crate::shopping::money::format_value(variant.get("price")), + "list_price": crate::shopping::money::format_value(variant.get("list_price")), + "available": variant.pointer("/availability/available").and_then(Value::as_bool).unwrap_or(false), + })) + }) + .collect::>(), + })) + .collect::>(), + "messages": response.get("messages").cloned().unwrap_or_else(|| json!([])), + }) +} + +fn categories(product: &Value) -> String { + product + .get("categories") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|category| category.get("value").and_then(Value::as_str)) + .collect::>() + .join(", ") +} + +fn price_range(product: &Value) -> Option { + let range = product.get("price_range")?; + let min = crate::shopping::money::format_value(range.get("min"))?; + let max = crate::shopping::money::format_value(range.get("max"))?; + Some(if min == max { + min + } else { + format!("{min}–{max}") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn human_view_shows_lookup_essentials_without_ucp_metadata() { + let output = render_human(&human_response(&json!({ + "products": [{ + "id": "product-1", + "title": "Product", + "categories": [{"value": "email"}], + "price_range": { + "min": {"amount": 7188, "currency": "USD"}, + "max": {"amount": 7188, "currency": "USD"} + }, + "variants": [{ + "id": "product-1:1yr", + "title": "One year", + "availability": {"available": true}, + "price": {"amount": 7188, "currency": "USD"}, + "list_price": {"amount": 11988, "currency": "USD"} + }] + }], + "ucp": {"do_not_render": true} + }))); + + assert!(output.contains("Product (ID: product-1)")); + assert!(output.contains("USD 71.88")); + assert!(!output.contains("do_not_render")); + } +} + +fn render_human(response: &Value) -> String { + let products = response + .get("products") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + let mut output = if products.is_empty() { + "No products found.\n".to_owned() + } else { + String::new() + }; + for product in products { + output.push_str(&format!( + "{} (ID: {})\n", + product + .get("title") + .and_then(Value::as_str) + .unwrap_or("Untitled product"), + product + .get("id") + .and_then(Value::as_str) + .unwrap_or_default(), + )); + if let Some(category) = product.get("category").and_then(Value::as_str) + && !category.is_empty() + { + output.push_str(&format!("Category: {category}\n")); + } + if let Some(price_range) = product.get("price_range").and_then(Value::as_str) { + output.push_str(&format!("Price range: {price_range}\n")); + } + output.push_str("Variants:\n"); + for variant in product + .get("variants") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let availability = if variant + .get("available") + .and_then(Value::as_bool) + .unwrap_or(false) + { + "Available" + } else { + "Unavailable" + }; + output.push_str(&format!( + "- {} (ID: {})\n Price: {} · List price: {} · {availability}\n", + variant + .get("title") + .and_then(Value::as_str) + .unwrap_or("Untitled variant"), + variant + .get("id") + .and_then(Value::as_str) + .unwrap_or_default(), + variant + .get("price") + .and_then(Value::as_str) + .unwrap_or("Unavailable"), + variant + .get("list_price") + .and_then(Value::as_str) + .unwrap_or("Unavailable"), + )); + } + output.push('\n'); + } + let messages = response + .get("messages") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + for message in messages { + if let Some(content) = message.get("content").and_then(Value::as_str) { + output.push_str(&format!("Message: {content}\n")); + } + } + output +} diff --git a/rust/src/shopping/catalog/mod.rs b/rust/src/shopping/catalog/mod.rs index 982c0838..bd210648 100644 --- a/rust/src/shopping/catalog/mod.rs +++ b/rust/src/shopping/catalog/mod.rs @@ -1,5 +1,5 @@ pub(super) mod get; -mod lookup; +pub(super) mod lookup; pub(super) mod search; use cli_engine::{GroupSpec, RuntimeGroupSpec}; diff --git a/rust/src/shopping/catalog/search.rs b/rust/src/shopping/catalog/search.rs index 445047f3..bddd3efd 100644 --- a/rust/src/shopping/catalog/search.rs +++ b/rust/src/shopping/catalog/search.rs @@ -1,7 +1,10 @@ -use cli_engine::{CommandResult, CommandSpec, ModuleContext, Result, RuntimeCommandSpec, Tier}; +use cli_engine::{ + CommandResult, CommandSpec, ModuleContext, NextAction, NextActionParam, Result, + RuntimeCommandSpec, Tier, +}; use serde_json::{Value, json}; -use crate::next_action::{next_action, required_value}; +use crate::next_action::{human_next_steps, next_action}; use crate::output_schema::output_schema; use crate::shopping::common::{ client_err, currency_code, make_client, merge_context_currency, read_json, @@ -105,7 +108,7 @@ pub(crate) fn register_human_view(ctx: &mut ModuleContext<'_>) { .register_func(HUMAN_VIEW_ID, render_human); } -fn human_response(response: &Value, actions: &[cli_engine::NextAction]) -> Value { +fn human_response(response: &Value, actions: &[NextAction]) -> Value { let products = response .get("products") .and_then(Value::as_array) @@ -145,13 +148,7 @@ fn human_response(response: &Value, actions: &[cli_engine::NextAction]) -> Value .collect::>(), })) .collect::>(), - "next_steps": actions - .iter() - .map(|action| json!({ - "command": action.command, - "description": action.description, - })) - .collect::>(), + "next_steps": human_next_steps(actions), }) } @@ -257,17 +254,13 @@ fn merge_pagination(request: &mut Value, limit: Option) -> Result<()> { Ok(()) } -fn next_actions( - response: &Value, - request: &mut Value, - env: &str, -) -> Result> { +fn next_actions(response: &Value, request: &mut Value, env: &str) -> Result> { let mut actions = product_actions(response, request, env); actions.extend(next_page_action(response, request, env)?); Ok(actions) } -fn product_actions(response: &Value, request: &Value, env: &str) -> Vec { +fn product_actions(response: &Value, request: &Value, env: &str) -> Vec { let Some(product) = response .get("products") .and_then(Value::as_array) @@ -279,51 +272,44 @@ fn product_actions(response: &Value, request: &Value, env: &str) -> Vec"), + "View the selected product's complete record", + ) + .with_param("product-id", NextActionParam::value(product_id)); if let Some(currency) = currency { - get_command.push_str(&format!(" --currency {currency}")); + get_action = get_action.with_param("currency", NextActionParam::value(currency)); + get_action.command.push_str(" --currency "); } - let mut actions = vec![next_action( - command_for_env(env, get_command), - "View the selected product's complete record", - )]; - if let Some(variant_id) = product + let mut actions = vec![get_action]; + if let Some(variant) = product .get("variants") .and_then(Value::as_array) .and_then(|variants| variants.iter().find(|variant| is_available(variant))) - .and_then(|variant| variant.get("id")) - .and_then(Value::as_str) { - let currency = product - .get("variants") - .and_then(Value::as_array) - .and_then(|variants| { - variants - .iter() - .find(|variant| variant.get("id").and_then(Value::as_str) == Some(variant_id)) - }) - .and_then(|variant| variant.pointer("/price/currency")) + let Some(variant_id) = variant.get("id").and_then(Value::as_str) else { + return actions; + }; + let currency = variant + .pointer("/price/currency") .and_then(Value::as_str) .unwrap_or("USD"); actions.push( next_action( command_for_env( env, - format!("checkout create --item '{variant_id}' --currency {currency}"), + "checkout create --item --currency ", ), "Create a checkout with the first available variant", ) - .with_param("variant_id", required_value(variant_id)), + .with_param("variant-id", NextActionParam::value(variant_id)) + .with_param("currency", NextActionParam::value(currency)), ); } actions } -fn next_page_action( - response: &Value, - request: &mut Value, - env: &str, -) -> Result> { +fn next_page_action(response: &Value, request: &mut Value, env: &str) -> Result> { let Some(pagination) = response.get("pagination") else { return Ok(Vec::new()); }; @@ -351,64 +337,80 @@ fn next_page_action( crate::error::GddyError::validation("pagination must be a JSON object").into_cli_error() })?; pagination.insert("cursor".to_owned(), json!(cursor)); - let command = search_command(request)?; - Ok(vec![next_action( - command_for_env(env, command), - "Fetch the next catalog page", - )]) + Ok(vec![search_action(request, env)?]) } -fn search_command(request: &serde_json::Map) -> Result { - if is_simple_search_request(request) { - let mut command = "catalog search".to_owned(); - append_search_flag(&mut command, "query", request.get("query")); - let filters = request.get("filters").and_then(Value::as_object); - if let Some(categories) = filters - .and_then(|filters| filters.get("categories")) - .and_then(Value::as_array) - { - for category in categories.iter().filter_map(Value::as_str) { - command.push_str(&format!(" --category '{category}'")); - } - } - let pagination = request.get("pagination").and_then(Value::as_object); - append_search_flag( - &mut command, - "cursor", - pagination.and_then(|pagination| pagination.get("cursor")), - ); - if let Some(limit) = pagination - .and_then(|pagination| pagination.get("limit")) - .and_then(Value::as_u64) - { - command.push_str(&format!(" --limit {limit}")); - } - let context = request.get("context").and_then(Value::as_object); - append_search_flag( - &mut command, - "currency", - context.and_then(|context| context.get("currency")), - ); - append_search_flag( - &mut command, - "country", - context.and_then(|context| context.get("address_country")), - ); - Ok(command) - } else { - let encoded_request = serde_json::to_string(request).map_err(|error| { +fn search_action(request: &serde_json::Map, env: &str) -> Result { + if !is_simple_search_request(request) { + let body = serde_json::to_string(request).map_err(|error| { crate::error::GddyError::unexpected(format!( "failed to encode next-page request: {error}" )) .into_cli_error() })?; - Ok(format!("catalog search --body '{encoded_request}'")) + return Ok(next_action( + command_for_env(env, "catalog search --body "), + "Fetch the next catalog page", + ) + .with_param("body", NextActionParam::value(body))); + } + + let mut command = "catalog search".to_owned(); + let mut params = Vec::new(); + append_search_param(&mut command, &mut params, "query", request.get("query")); + if let Some(categories) = request + .get("filters") + .and_then(Value::as_object) + .and_then(|filters| filters.get("categories")) + .and_then(Value::as_array) + { + for (index, category) in categories.iter().filter_map(Value::as_str).enumerate() { + let name = format!("category-{index}"); + command.push_str(&format!(" --category <{name}>")); + params.push((name, category.to_owned())); + } + } + let pagination = request.get("pagination").and_then(Value::as_object); + append_search_param( + &mut command, + &mut params, + "cursor", + pagination.and_then(|pagination| pagination.get("cursor")), + ); + if let Some(limit) = pagination + .and_then(|pagination| pagination.get("limit")) + .and_then(Value::as_u64) + { + command.push_str(&format!(" --limit {limit}")); } + let context = request.get("context").and_then(Value::as_object); + append_search_param( + &mut command, + &mut params, + "currency", + context.and_then(|context| context.get("currency")), + ); + append_search_param( + &mut command, + &mut params, + "country", + context.and_then(|context| context.get("address_country")), + ); + Ok(params.into_iter().fold( + next_action(command_for_env(env, command), "Fetch the next catalog page"), + |action, (name, value)| action.with_param(name, NextActionParam::value(value)), + )) } -fn append_search_flag(command: &mut String, name: &str, value: Option<&Value>) { +fn append_search_param( + command: &mut String, + params: &mut Vec<(String, String)>, + name: &str, + value: Option<&Value>, +) { if let Some(value) = value.and_then(Value::as_str) { - command.push_str(&format!(" --{name} '{value}'")); + command.push_str(&format!(" --{name} <{name}>")); + params.push((name.to_owned(), value.to_owned())); } } @@ -659,7 +661,7 @@ mod tests { use super::{ human_response, merge_pagination, merge_search_args, next_actions, render_human, - validate_price_filter_currency, + search_action, validate_price_filter_currency, }; use crate::shopping::command_for_env; use crate::shopping::common::{currency_code, merge_context_currency}; @@ -743,9 +745,72 @@ mod tests { .iter() .all(|action| action.command.contains("gddy --env test")) ); - assert!(actions[0].command.contains("catalog get --id 'product-1'")); - assert!(actions[1].command.contains("checkout create")); - assert!(actions[2].command.contains("--cursor 'next'")); + assert_eq!( + actions[0].command, + "gddy --env test shopping catalog get --id " + ); + assert_eq!( + actions[0].params["product-id"].value.as_deref(), + Some("product-1") + ); + assert_eq!( + actions[1].command, + "gddy --env test shopping checkout create --item --currency " + ); + assert_eq!( + actions[1].params["variant-id"].value.as_deref(), + Some("product-1:1yr") + ); + assert_eq!(actions[2].params["cursor"].value.as_deref(), Some("next")); + assert_eq!( + actions[2].command, + "gddy --env test shopping catalog search --cursor --limit 3" + ); + } + + #[test] + fn next_actions_keep_dynamic_values_in_structured_parameters() { + let response = response(); + let mut request = json!({ + "query": "O'Reilly", + "filters": {"categories": ["web & email"]}, + "pagination": {"limit": 3} + }); + let actions = next_actions(&response, &mut request, "test").expect("actions"); + + assert_eq!( + actions[0].params["product-id"].value.as_deref(), + Some("product-1") + ); + assert_eq!( + actions[2].params["query"].value.as_deref(), + Some("O'Reilly") + ); + assert_eq!( + actions[2].params["category-0"].value.as_deref(), + Some("web & email") + ); + assert_eq!(actions[2].params["cursor"].value.as_deref(), Some("next")); + assert!(!actions[2].command.contains("O'Reilly")); + assert!(!actions[2].command.contains("web & email")); + } + + #[test] + fn advanced_next_page_keeps_json_in_a_structured_parameter() { + let action = search_action( + &serde_json::from_value(json!({"signals": {"value": "O'Reilly"}})).expect("object"), + "test", + ) + .expect("action"); + + assert_eq!( + action.command, + "gddy --env test shopping catalog search --body " + ); + assert_eq!( + action.params["body"].value.as_deref(), + Some(r#"{"signals":{"value":"O'Reilly"}}"#) + ); } #[test] @@ -792,8 +857,8 @@ mod tests { let actions = next_actions(&response, &mut request, "test").expect("actions"); assert_eq!(request.pointer("/context/currency"), Some(&json!("jpy"))); - assert!(actions[1].command.contains("--currency USD")); - assert!(actions[2].command.contains("--currency 'jpy'")); + assert_eq!(actions[1].params["currency"].value.as_deref(), Some("USD")); + assert_eq!(actions[2].params["currency"].value.as_deref(), Some("jpy")); } #[test] diff --git a/rust/src/shopping/checkout/complete.rs b/rust/src/shopping/checkout/complete.rs index afe9673e..22a292b5 100644 --- a/rust/src/shopping/checkout/complete.rs +++ b/rust/src/shopping/checkout/complete.rs @@ -1,7 +1,9 @@ -use cli_engine::{CommandResult, CommandSpec, ModuleContext, RuntimeCommandSpec, Tier}; +use cli_engine::{ + CommandResult, CommandSpec, ModuleContext, NextActionParam, RuntimeCommandSpec, Tier, +}; use serde_json::{Value, json}; -use crate::next_action::{next_action, required_value}; +use crate::next_action::{human_next_steps, next_action}; use crate::shopping::client::ClientError; use crate::shopping::common::{ CheckoutInput, ensure_completion_idempotency_key, has_conflicting_checkout_id, make_client, @@ -50,8 +52,12 @@ fn human_response( "status": completion.get("status").and_then(Value::as_str).unwrap_or_default(), "order_id": completion.pointer("/order/id").and_then(Value::as_str), "order_permalink": completion.pointer("/order/permalink_url").and_then(Value::as_str), + "total": crate::shopping::money::format_total( + completion.get("totals"), + completion.get("currency").and_then(Value::as_str), + ), "idempotency_key": idempotency_key, - "next_steps": actions.iter().map(|action| json!({"command": action.command, "description": action.description})).collect::>(), + "next_steps": human_next_steps(actions), }) } @@ -93,11 +99,57 @@ fn render_human(completion: &Value) -> String { if let Some(permalink) = completion.get("order_permalink").and_then(Value::as_str) { output.push_str(&format!("View order: {permalink}\n")); } + if let Some(total) = completion.get("total").and_then(Value::as_str) { + output.push_str(&format!("Total: {total}\n")); + } output.push_str("\nKeep this idempotency key. Do not retry a completion unless you first confirm its outcome.\n"); crate::shopping::checkout::get::render_next_steps(&mut output, completion); output } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn human_view_shows_completion_total_only_with_currency() { + let output = render_human(&human_response( + &json!({ + "id": "checkout-1", + "status": "completed", + "currency": "GBP", + "totals": [ + {"type": "subtotal", "amount": 4788}, + {"type": "tax", "amount": 0}, + {"type": "total", "amount": 4788} + ] + }), + "idempotency-key", + &[], + )); + + assert!(output.contains("Total: GBP 47.88")); + assert!(!output.contains("Subtotal:")); + assert!(!output.contains("Tax:")); + } + + #[test] + fn human_view_omits_completion_total_without_currency() { + let output = render_human(&human_response( + &json!({ + "id": "checkout-1", + "status": "completed", + "totals": [{"type": "total", "amount": 4788}] + }), + "idempotency-key", + &[], + )); + + assert!(!output.contains("Total:")); + assert!(!output.contains("4788")); + } +} + fn completion_error(error: ClientError, idempotency_key: &str) -> cli_engine::CliCoreError { crate::error::GddyError::from(error) .with_fix(format!( @@ -160,7 +212,8 @@ pub(super) fn command() -> RuntimeCommandSpec { "id": args.id, "idempotency_key": idempotency_key, "body": body, - }))); + })) + .with_dry_run()); } let client = make_client(&ctx).await?; @@ -174,14 +227,10 @@ pub(super) fn command() -> RuntimeCommandSpec { let actions = order_id.map_or_else(Vec::new, |order_id| { vec![ next_action( - command_for_env( - &ctx.middleware.env, - format!("order get {order_id} --wait"), - ), + command_for_env(&ctx.middleware.env, "order get --wait"), "Read the completed order after it becomes visible", ) - .with_param("order_id", required_value(order_id)) - .with_param("idempotency_key", required_value(idempotency_key.clone())), + .with_param("order-id", NextActionParam::value(order_id)), ] }); let output = if ctx.middleware.output_format == "human" { diff --git a/rust/src/shopping/checkout/create.rs b/rust/src/shopping/checkout/create.rs index 5f491ae1..486ff969 100644 --- a/rust/src/shopping/checkout/create.rs +++ b/rust/src/shopping/checkout/create.rs @@ -1,7 +1,7 @@ -use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; use serde_json::Value; -use crate::next_action::{next_action, required_value}; +use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::shopping::checkout::get::{HUMAN_VIEW_ID, human_response}; use crate::shopping::common::{ @@ -52,6 +52,10 @@ struct Args { #[arg(long, value_name = "INSTRUMENT_ID")] payment_instrument: Option, + /// Show every available saved payment instrument instead of the first five. + #[arg(long)] + show_all_payment_instruments: bool, + /// Checkout-create request as raw JSON for advanced Shopping API fields. #[arg(long, value_name = "JSON")] body: Option, @@ -100,7 +104,8 @@ pub(super) fn command() -> RuntimeCommandSpec { return Ok(CommandResult::new(serde_json::json!({ "action": "dry-run: would create checkout", "body": body, - }))); + })) + .with_dry_run()); } let client = make_client(&ctx).await?; let checkout = client.create_checkout(body).await.map_err(client_err)?; @@ -135,15 +140,19 @@ pub(super) fn command() -> RuntimeCommandSpec { next_action( command_for_env( &ctx.middleware.env, - format!("checkout complete {checkout_id} --payment-instrument {payment_instrument}"), + "checkout complete --payment-instrument ", ), "Complete this checkout with a selected saved payment instrument", ) - .with_param("checkout_id", required_value(checkout_id)), + .with_param("checkout-id", NextActionParam::value(checkout_id)) + .with_param( + "payment-instrument", + NextActionParam::value(payment_instrument), + ), ); } let output = if ctx.middleware.output_format == "human" { - human_response(&checkout, &actions) + human_response(&checkout, &actions, args.show_all_payment_instruments) } else { checkout }; diff --git a/rust/src/shopping/checkout/get.rs b/rust/src/shopping/checkout/get.rs index 70752fcb..85ba6b45 100644 --- a/rust/src/shopping/checkout/get.rs +++ b/rust/src/shopping/checkout/get.rs @@ -1,7 +1,10 @@ -use cli_engine::{CommandResult, CommandSpec, ModuleContext, Result, RuntimeCommandSpec, Tier}; +use cli_engine::{ + CommandResult, CommandSpec, ModuleContext, NextAction, NextActionParam, Result, + RuntimeCommandSpec, Tier, +}; use serde_json::{Value, json}; -use crate::next_action::next_action; +use crate::next_action::{human_next_steps, next_action}; use crate::shopping::common::{client_err, make_client}; use crate::shopping::money; use crate::shopping::{SHOPPING_SCOPES, command_for_env}; @@ -19,6 +22,10 @@ struct Args { /// Checkout session ID. #[arg(value_name = "CHECKOUT_ID")] id: String, + + /// Show every available saved payment instrument instead of the first five. + #[arg(long)] + show_all_payment_instruments: bool, } pub(super) fn command() -> RuntimeCommandSpec { @@ -40,21 +47,25 @@ pub(super) fn command() -> RuntimeCommandSpec { let actions = if ready_for_complete { let payment_instrument = selected_payment_id(&checkout).unwrap_or(""); - vec![next_action( - command_for_env( - &ctx.middleware.env, - format!( - "checkout complete {} --payment-instrument {payment_instrument}", - args.id + vec![ + next_action( + command_for_env( + &ctx.middleware.env, + "checkout complete --payment-instrument ", ), + "Complete this checkout after reviewing its selected payment method", + ) + .with_param("checkout-id", NextActionParam::value(args.id)) + .with_param( + "payment-instrument", + NextActionParam::value(payment_instrument), ), - "Complete this checkout after reviewing its selected payment method", - )] + ] } else { Vec::new() }; let output = if ctx.middleware.output_format == "human" { - human_response(&checkout, &actions) + human_response(&checkout, &actions, args.show_all_payment_instruments) } else { checkout }; @@ -68,7 +79,11 @@ async fn client_response(ctx: &cli_engine::CommandContext, id: &str) -> Result Value { +pub(super) fn human_response( + checkout: &Value, + actions: &[NextAction], + show_all_payment_instruments: bool, +) -> Value { let line_items = checkout .get("line_items") .and_then(Value::as_array) @@ -90,20 +105,61 @@ pub(super) fn human_response(checkout: &Value, actions: &[cli_engine::NextAction "status": checkout.get("status").and_then(Value::as_str).unwrap_or_default(), "items": line_items, "currency": checkout.get("currency").and_then(Value::as_str).unwrap_or_default(), - "totals": checkout.get("totals").cloned().unwrap_or_else(|| json!([])), + "total": money::format_total( + checkout.get("totals"), + checkout.get("currency").and_then(Value::as_str), + ), "selected_payment": selected_payment(checkout), - "next_steps": actions.iter().map(|action| json!({"command": action.command, "description": action.description})).collect::>(), + "available_payment_instruments": available_payment_instruments(checkout, show_all_payment_instruments), + "has_more_payment_instruments": !show_all_payment_instruments && available_payment_instrument_count(checkout) > PAYMENT_INSTRUMENT_LIMIT, + "next_steps": human_next_steps(actions), }) } +const PAYMENT_INSTRUMENT_LIMIT: usize = 5; + +fn available_payment_instruments(checkout: &Value, show_all: bool) -> Vec { + checkout + .pointer("/payment/instruments") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|instrument| { + let id = instrument.get("id").and_then(Value::as_str)?; + Some(json!({ + "id": id, + "description": payment_instrument_description(instrument), + "selected": instrument.get("selected").and_then(Value::as_bool).unwrap_or(false), + })) + }) + .take(if show_all { + usize::MAX + } else { + PAYMENT_INSTRUMENT_LIMIT + }) + .collect() +} + +fn available_payment_instrument_count(checkout: &Value) -> usize { + checkout + .pointer("/payment/instruments") + .and_then(Value::as_array) + .map_or(0, Vec::len) +} + +fn payment_instrument_description(instrument: &Value) -> &str { + instrument + .get("rich_text_description") + .or_else(|| instrument.get("description")) + .and_then(Value::as_str) + .unwrap_or("Saved payment method") +} + fn selected_payment(checkout: &Value) -> String { let Some(instrument) = selected_payment_instrument(checkout) else { return "No payment method selected".to_owned(); }; - let description = instrument - .get("rich_text_description") - .and_then(Value::as_str) - .unwrap_or("Selected payment method"); + let description = payment_instrument_description(instrument); match instrument.get("id").and_then(Value::as_str) { Some(id) => format!("{description} (ID: {id})"), None => description.to_owned(), @@ -175,44 +231,60 @@ fn render_human(checkout: &Value) -> String { } } output.push_str(&format!( - "\nSelected payment: {}\nTotals:\n", + "\nSelected payment: {}\n", checkout .get("selected_payment") .and_then(Value::as_str) .unwrap_or("No payment method selected"), )); - let totals = checkout - .get("totals") + render_available_payment_instruments(&mut output, checkout); + if let Some(total) = checkout.get("total").and_then(Value::as_str) { + output.push_str(&format!("\nTotal: {total}\n")); + } + output.push_str("\nCompletion places a real order. Review this checkout before continuing.\n"); + render_next_steps(&mut output, checkout); + output +} + +fn render_available_payment_instruments(output: &mut String, checkout: &Value) { + let instruments = checkout + .get("available_payment_instruments") .and_then(Value::as_array) .map(Vec::as_slice) .unwrap_or_default(); - if totals.is_empty() { - output.push_str("- None\n"); + if instruments.is_empty() { + return; } - for total in totals { - let label = total - .get("display_text") - .or_else(|| total.get("type")) - .and_then(Value::as_str) - .unwrap_or("Total"); - let amount = total - .get("amount") - .and_then(Value::as_i64) - .unwrap_or_default(); + output.push_str("\nAvailable payment methods:\n"); + for instrument in instruments { + let selected = if instrument + .get("selected") + .and_then(Value::as_bool) + .unwrap_or(false) + { + " (selected)" + } else { + "" + }; output.push_str(&format!( - "- {label}: {}\n", - checkout - .get("currency") + "- {} (ID: {}){selected}\n", + instrument + .get("description") + .and_then(Value::as_str) + .unwrap_or("Saved payment method"), + instrument + .get("id") .and_then(Value::as_str) - .map_or_else( - || amount.to_string(), - |currency| money::format_amount(amount, currency) - ), + .unwrap_or_default(), )); } - output.push_str("\nCompletion places a real order. Review this checkout before continuing.\n"); - render_next_steps(&mut output, checkout); - output + if checkout + .get("has_more_payment_instruments") + .and_then(Value::as_bool) + .unwrap_or(false) + { + output.push_str("Use --show-all-payment-instruments to show every saved payment method.\n"); + } } pub(super) fn render_next_steps(output: &mut String, response: &Value) { @@ -242,6 +314,23 @@ pub(super) fn render_next_steps(output: &mut String, response: &Value) { mod tests { use super::*; + #[test] + fn payment_instruments_default_to_five_and_can_show_all() { + let checkout = json!({ + "payment": {"instruments": (1..=6) + .map(|id| json!({"id": id.to_string(), "description": format!("Card {id}")})) + .collect::>()} + }); + + let default_instruments = available_payment_instruments(&checkout, false); + let all_instruments = available_payment_instruments(&checkout, true); + + assert_eq!(default_instruments.len(), 5); + assert_eq!(all_instruments.len(), 6); + let output = render_human(&human_response(&checkout, &[], false)); + assert!(output.contains("--show-all-payment-instruments")); + } + #[test] fn human_view_masks_checkout_to_purchase_essentials() { let output = render_human(&human_response( @@ -253,18 +342,23 @@ mod tests { "item": {"title": "Web Hosting Economy"}, "included_products": [{"title": "Standard SSL"}] }], + "totals": [{"type": "total", "amount": 8388}], + "currency": "USD", "payment": {"instruments": [{ + "id": "payment-1", "selected": true, "rich_text_description": "CREDIT_CARD/VISA 1111", "billing_address": {"street_address": "do not render"} - }]}, - "totals": [{"display_text": "Total", "amount": 8388}] + }]} }), &[], + false, )); assert!(output.contains("Web Hosting Economy")); assert!(output.contains("CREDIT_CARD/VISA 1111")); + assert!(output.contains("Available payment methods:")); + assert!(output.contains("Total: USD 83.88")); assert!(output.contains("Completion places a real order")); assert!(!output.contains("do not render")); } diff --git a/rust/src/shopping/checkout/update.rs b/rust/src/shopping/checkout/update.rs index 242ca0ba..97ed145a 100644 --- a/rust/src/shopping/checkout/update.rs +++ b/rust/src/shopping/checkout/update.rs @@ -99,7 +99,8 @@ pub(super) fn command() -> RuntimeCommandSpec { "action": "dry-run: would update checkout", "id": args.id, "body": body, - }))); + })) + .with_dry_run()); } let client = make_client(&ctx).await?; let checkout = client @@ -115,7 +116,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .into_iter() .collect::>(); let output = if ctx.middleware.output_format == "human" { - crate::shopping::checkout::get::human_response(&checkout, &actions) + crate::shopping::checkout::get::human_response(&checkout, &actions, false) } else { checkout }; diff --git a/rust/src/shopping/client.rs b/rust/src/shopping/client.rs index 635ee6fc..ab9c9c88 100644 --- a/rust/src/shopping/client.rs +++ b/rust/src/shopping/client.rs @@ -3,6 +3,7 @@ use std::time::Duration; use reqwest::{Client, Method}; use serde_json::{Value, json}; +use crate::api_explorer::http::encode_path_segment; use crate::application::client::make_http_client; const BASE_PATH: &str = "/v1/shopping"; @@ -144,30 +145,33 @@ impl ShoppingClient { } pub async fn get_checkout(&self, id: &str) -> Result { - self.send_json(Method::GET, &format!("/checkout-sessions/{id}"), None) + self.send_json(Method::GET, &checkout_path(id, ""), None) .await } pub async fn update_checkout(&self, id: &str, body: Value) -> Result { - self.send_json(Method::PUT, &format!("/checkout-sessions/{id}"), Some(body)) + self.send_json(Method::PUT, &checkout_path(id, ""), Some(body)) .await } pub async fn complete_checkout(&self, id: &str, body: Value) -> Result { - self.send_json( - Method::POST, - &format!("/checkout-sessions/{id}/complete"), - Some(body), - ) - .await + self.send_json(Method::POST, &checkout_path(id, "/complete"), Some(body)) + .await } pub async fn get_order(&self, id: &str) -> Result { - self.send_json(Method::GET, &format!("/orders/{id}"), None) - .await + self.send_json(Method::GET, &order_path(id), None).await } } +fn checkout_path(id: &str, suffix: &str) -> String { + format!("/checkout-sessions/{}{suffix}", encode_path_segment(id)) +} + +fn order_path(id: &str) -> String { + format!("/orders/{}", encode_path_segment(id)) +} + #[cfg(test)] mod tests { use httpmock::prelude::*; @@ -187,6 +191,15 @@ mod tests { ); } + #[test] + fn encodes_dynamic_ids_as_path_segments() { + assert_eq!( + checkout_path("session/a?b#c%d", "/complete"), + "/checkout-sessions/session%2Fa%3Fb%23c%25d/complete" + ); + assert_eq!(order_path("order/a?b#c%d"), "/orders/order%2Fa%3Fb%23c%25d"); + } + #[tokio::test] async fn surfaces_order_not_found_as_retryable() { let server = MockServer::start_async().await; diff --git a/rust/src/shopping/common.rs b/rust/src/shopping/common.rs index 40e4fa51..2e76139b 100644 --- a/rust/src/shopping/common.rs +++ b/rust/src/shopping/common.rs @@ -240,9 +240,7 @@ pub(crate) fn merge_context_currency(request: &mut Value, currency: Option<&str> let object = request .as_object_mut() .expect("read_json validates the request is an object"); - let context = object - .entry("context") - .or_insert_with(|| serde_json::json!({})); + let context = object.entry("context").or_insert_with(|| json!({})); let context = context .as_object_mut() .ok_or_else(|| GddyError::validation("context must be a JSON object").into_cli_error())?; @@ -375,7 +373,9 @@ pub(crate) async fn wait_for_order( let remaining = timeout.saturating_sub(started.elapsed()); let retry_delay = error.retry_after().unwrap_or(delay).min(remaining); if retry_delay.is_zero() { - break; + return Err(exhausted_order_read_error( + error, order_id, attempts, timeout, env, + )); } tracing::debug!( order_id, @@ -386,21 +386,36 @@ pub(crate) async fn wait_for_order( tokio::time::sleep(retry_delay).await; delay = delay.saturating_mul(2).min(Duration::from_secs(4)); } - Err(error) if error.is_retryable_order_read() => { - return Err(GddyError::not_found(format!( - "order {order_id:?} was not visible after {attempts} attempts over {} seconds", - timeout.as_secs_f32() - )) - .with_fix(format!( - "Run: gddy {}", - crate::shopping::command_for_env(env, format!("order get {order_id} --wait")) - )) - .into_cli_error()); + Err(error) => { + return Err(exhausted_order_read_error( + error, order_id, attempts, timeout, env, + )); } - Err(error) => return Err(client_err(error)), } } - Err(GddyError::not_found(format!( +} + +fn exhausted_order_read_error( + error: ClientError, + order_id: &str, + attempts: usize, + timeout: Duration, + env: &str, +) -> CliCoreError { + if matches!(error, ClientError::Http { status: 404, .. }) { + order_not_visible_error(order_id, attempts, timeout, env) + } else { + client_err(error) + } +} + +fn order_not_visible_error( + order_id: &str, + attempts: usize, + timeout: Duration, + env: &str, +) -> CliCoreError { + GddyError::not_found(format!( "order {order_id:?} was not visible after {attempts} attempts over {} seconds", timeout.as_secs_f32() )) @@ -408,7 +423,7 @@ pub(crate) async fn wait_for_order( "Run: gddy {}", crate::shopping::command_for_env(env, format!("order get {order_id} --wait")) )) - .into_cli_error()) + .into_cli_error() } pub(crate) fn wait_duration(seconds: Option) -> Result { @@ -630,6 +645,36 @@ mod tests { ); } + #[test] + fn exhausted_order_read_maps_only_404_to_not_found() { + let not_found = exhausted_order_read_error( + ClientError::Http { + status: 404, + body: "not found".to_owned(), + retry_after: None, + }, + "order-1", + 1, + Duration::ZERO, + "test", + ); + let rate_limited = exhausted_order_read_error( + ClientError::Http { + status: 429, + body: "rate limited".to_owned(), + retry_after: None, + }, + "order-1", + 1, + Duration::ZERO, + "test", + ); + + assert!(not_found.to_string().contains("was not visible")); + assert!(!rate_limited.to_string().contains("was not visible")); + assert!(rate_limited.to_string().contains("429")); + } + #[test] fn validates_wait_timeout_range() { assert_eq!( diff --git a/rust/src/shopping/guides/shopping.md b/rust/src/shopping/guides/shopping.md index 134b0557..2325e37f 100644 --- a/rust/src/shopping/guides/shopping.md +++ b/rust/src/shopping/guides/shopping.md @@ -110,9 +110,11 @@ request through `--body` or `--file`. ## Create a checkout ready to complete Creating a checkout does not place an order. A create response includes the checkout ID, -priced line items, totals, and available payment instruments, so a separate `checkout get` -is not required before completion when the checkout is already ready. Include buyer, payment, -and other supported checkout information when creating a checkout that is ready to complete. +priced line items, total, and up to five available payment instruments with IDs and +descriptions, so a separate `checkout get` is not required before completion when the +checkout is already ready. Add `--show-all-payment-instruments` to show every available +method. Include buyer, payment, and other supported checkout information when creating a +checkout that is ready to complete. ```bash gddy shopping checkout create \ @@ -183,8 +185,10 @@ Only one payment instrument may be specified for checkout create, update, or com `--file checkout.json` rather than placing address information in shell history. Use `checkout get ` when you need to inspect an existing open checkout or -recover its available payment instruments. Its human output shows checkout status, items, -totals, and the selected masked payment method. Use `--output json` for the full response. +recover its available payment instruments. Human output shows checkout status, items, the +selected payment method, and up to five available saved payment methods with their IDs and +descriptions. Add `--show-all-payment-instruments` to show every available method. Use +`--output json` for the full response. ## Optionally update an open checkout diff --git a/rust/src/shopping/mod.rs b/rust/src/shopping/mod.rs index 408a3ee0..9fa95428 100644 --- a/rust/src/shopping/mod.rs +++ b/rust/src/shopping/mod.rs @@ -9,6 +9,7 @@ mod order; use cli_engine::{GroupSpec, Module, RuntimeGroupSpec}; use crate::shopping::catalog::get::register_human_view as register_catalog_get_human_view; +use crate::shopping::catalog::lookup::register_human_view as register_catalog_lookup_human_view; use crate::shopping::catalog::search::register_human_view as register_catalog_search_human_view; use crate::shopping::checkout::complete::register_human_view as register_checkout_complete_human_view; use crate::shopping::checkout::get::register_human_view as register_checkout_get_human_view; @@ -36,6 +37,7 @@ pub(crate) fn command_for_env(env: &str, command: impl AsRef) -> String { pub fn module() -> Module { Module::new("Shopping", |ctx| { register_catalog_get_human_view(ctx); + register_catalog_lookup_human_view(ctx); register_catalog_search_human_view(ctx); register_checkout_complete_human_view(ctx); register_checkout_get_human_view(ctx); diff --git a/rust/src/shopping/money.rs b/rust/src/shopping/money.rs index 5239b3e0..bb799d03 100644 --- a/rust/src/shopping/money.rs +++ b/rust/src/shopping/money.rs @@ -8,6 +8,17 @@ pub(crate) fn format_value(value: Option<&Value>) -> Option { Some(format_amount(amount, currency)) } +pub(crate) fn format_total(totals: Option<&Value>, currency: Option<&str>) -> Option { + let currency = currency?; + let amount = totals? + .as_array()? + .iter() + .find(|total| total.get("type").and_then(Value::as_str) == Some("total"))? + .get("amount")? + .as_i64()?; + Some(format_amount(amount, currency)) +} + pub(crate) fn format_amount(amount: i64, currency: &str) -> String { let sign = if amount < 0 { "-" } else { "" }; let absolute = amount.unsigned_abs(); @@ -60,6 +71,34 @@ mod tests { assert_eq!(format_amount(12_345_678, "CLF"), "CLF 1,234.5678"); } + #[test] + fn formats_total_only_when_currency_and_total_are_present() { + assert_eq!( + format_total( + Some(&serde_json::json!([ + {"type": "subtotal", "amount": 7188}, + {"type": "total", "amount": 7988} + ])), + Some("USD") + ), + Some("USD 79.88".to_owned()) + ); + assert_eq!( + format_total( + Some(&serde_json::json!([{"type": "total", "amount": 7988}])), + None + ), + None + ); + assert_eq!( + format_total( + Some(&serde_json::json!([{"type": "subtotal", "amount": 7188}])), + Some("USD") + ), + None + ); + } + #[test] fn formats_negative_and_unknown_currency_amounts() { assert_eq!(format_amount(-500, "USD"), "USD -5.00"); diff --git a/rust/src/shopping/order/get.rs b/rust/src/shopping/order/get.rs index 2f4da4fe..c04a66d2 100644 --- a/rust/src/shopping/order/get.rs +++ b/rust/src/shopping/order/get.rs @@ -78,7 +78,10 @@ fn human_response(order: &Value) -> Value { "id": order.get("id").and_then(Value::as_str).unwrap_or_default(), "permalink_url": order.get("permalink_url").and_then(Value::as_str), "line_items": order.get("line_items").cloned().unwrap_or_else(|| json!([])), - "totals": order.get("totals").cloned().unwrap_or_else(|| json!([])), + "total": money::format_total( + order.get("totals"), + order.get("currency").and_then(Value::as_str), + ), "currency": order.get("currency").and_then(Value::as_str), "fulfillment": order.get("fulfillment").cloned().unwrap_or_else(|| json!({})), }) @@ -117,42 +120,13 @@ fn render_human(order: &Value) -> String { .unwrap_or("unknown"); output.push_str(&format!("- {quantity} × {title} · {status}\n")); } - if order.get("currency").and_then(Value::as_str).is_some() { - output.push_str("\nTotals:\n"); - render_totals(&mut output, order.get("totals"), order.get("currency")); + if let Some(total) = order.get("total").and_then(Value::as_str) { + output.push_str(&format!("\nTotal: {total}\n")); } render_fulfillment(&mut output, order.get("fulfillment")); output } -fn render_totals(output: &mut String, totals: Option<&Value>, currency: Option<&Value>) { - let Some(currency) = currency.and_then(Value::as_str) else { - return; - }; - let totals = totals - .and_then(Value::as_array) - .map(Vec::as_slice) - .unwrap_or_default(); - if totals.is_empty() { - output.push_str("- None\n"); - } - for total in totals { - let label = total - .get("display_text") - .or_else(|| total.get("type")) - .and_then(Value::as_str) - .unwrap_or("Total"); - let amount = total - .get("amount") - .and_then(Value::as_i64) - .unwrap_or_default(); - output.push_str(&format!( - "- {label}: {}\n", - money::format_amount(amount, currency) - )); - } -} - fn render_fulfillment(output: &mut String, fulfillment: Option<&Value>) { let Some(fulfillment) = fulfillment.and_then(Value::as_object) else { return; @@ -185,13 +159,14 @@ mod tests { "permalink_url": "https://example.test/order-1", "currency": "USD", "line_items": [{"item": {"title": "Web Hosting Economy"}, "quantity": {"total": 1}, "status": "fulfilled"}], - "totals": [{"display_text": "Total", "amount": 8388}], + "totals": [{"type": "total", "display_text": "Total", "amount": 8388}], "ucp": {"do_not_render": true} }))); assert!(output.contains("Order: order-1")); assert!(output.contains("Web Hosting Economy · fulfilled")); - assert!(output.contains("USD 83.88")); + assert!(output.contains("Total: USD 83.88")); + assert!(!output.contains("Subtotal:")); assert!(!output.contains("Checkout:")); assert!(!output.contains("do_not_render")); } From e1c3df599f8ddddd708c048393ac05a0a6fddb7f Mon Sep 17 00:00:00 2001 From: sswaminathan Date: Thu, 10 Sep 2026 16:16:18 -0700 Subject: [PATCH 13/14] test(shopping): expand client contract coverage Add httpmock coverage for catalog, checkout, and order routes, request metadata, encoded identifiers, empty successes, and HTTP error handling. Also move two existing test modules to the ends of their files to satisfy Clippy's items-after-test-module lint. Co-Authored-By: Claude --- rust/src/shopping/catalog/lookup.rs | 64 ++--- rust/src/shopping/checkout/complete.rs | 86 +++--- rust/src/shopping/client.rs | 366 ++++++++++++++++++++++++- 3 files changed, 440 insertions(+), 76 deletions(-) diff --git a/rust/src/shopping/catalog/lookup.rs b/rust/src/shopping/catalog/lookup.rs index 955f62b0..0e6b5132 100644 --- a/rust/src/shopping/catalog/lookup.rs +++ b/rust/src/shopping/catalog/lookup.rs @@ -142,38 +142,6 @@ fn price_range(product: &Value) -> Option { }) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn human_view_shows_lookup_essentials_without_ucp_metadata() { - let output = render_human(&human_response(&json!({ - "products": [{ - "id": "product-1", - "title": "Product", - "categories": [{"value": "email"}], - "price_range": { - "min": {"amount": 7188, "currency": "USD"}, - "max": {"amount": 7188, "currency": "USD"} - }, - "variants": [{ - "id": "product-1:1yr", - "title": "One year", - "availability": {"available": true}, - "price": {"amount": 7188, "currency": "USD"}, - "list_price": {"amount": 11988, "currency": "USD"} - }] - }], - "ucp": {"do_not_render": true} - }))); - - assert!(output.contains("Product (ID: product-1)")); - assert!(output.contains("USD 71.88")); - assert!(!output.contains("do_not_render")); - } -} - fn render_human(response: &Value) -> String { let products = response .get("products") @@ -255,3 +223,35 @@ fn render_human(response: &Value) -> String { } output } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn human_view_shows_lookup_essentials_without_ucp_metadata() { + let output = render_human(&human_response(&json!({ + "products": [{ + "id": "product-1", + "title": "Product", + "categories": [{"value": "email"}], + "price_range": { + "min": {"amount": 7188, "currency": "USD"}, + "max": {"amount": 7188, "currency": "USD"} + }, + "variants": [{ + "id": "product-1:1yr", + "title": "One year", + "availability": {"available": true}, + "price": {"amount": 7188, "currency": "USD"}, + "list_price": {"amount": 11988, "currency": "USD"} + }] + }], + "ucp": {"do_not_render": true} + }))); + + assert!(output.contains("Product (ID: product-1)")); + assert!(output.contains("USD 71.88")); + assert!(!output.contains("do_not_render")); + } +} diff --git a/rust/src/shopping/checkout/complete.rs b/rust/src/shopping/checkout/complete.rs index 22a292b5..d59a9777 100644 --- a/rust/src/shopping/checkout/complete.rs +++ b/rust/src/shopping/checkout/complete.rs @@ -107,49 +107,6 @@ fn render_human(completion: &Value) -> String { output } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn human_view_shows_completion_total_only_with_currency() { - let output = render_human(&human_response( - &json!({ - "id": "checkout-1", - "status": "completed", - "currency": "GBP", - "totals": [ - {"type": "subtotal", "amount": 4788}, - {"type": "tax", "amount": 0}, - {"type": "total", "amount": 4788} - ] - }), - "idempotency-key", - &[], - )); - - assert!(output.contains("Total: GBP 47.88")); - assert!(!output.contains("Subtotal:")); - assert!(!output.contains("Tax:")); - } - - #[test] - fn human_view_omits_completion_total_without_currency() { - let output = render_human(&human_response( - &json!({ - "id": "checkout-1", - "status": "completed", - "totals": [{"type": "total", "amount": 4788}] - }), - "idempotency-key", - &[], - )); - - assert!(!output.contains("Total:")); - assert!(!output.contains("4788")); - } -} - fn completion_error(error: ClientError, idempotency_key: &str) -> cli_engine::CliCoreError { crate::error::GddyError::from(error) .with_fix(format!( @@ -242,3 +199,46 @@ pub(super) fn command() -> RuntimeCommandSpec { }, ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn human_view_shows_completion_total_only_with_currency() { + let output = render_human(&human_response( + &json!({ + "id": "checkout-1", + "status": "completed", + "currency": "GBP", + "totals": [ + {"type": "subtotal", "amount": 4788}, + {"type": "tax", "amount": 0}, + {"type": "total", "amount": 4788} + ] + }), + "idempotency-key", + &[], + )); + + assert!(output.contains("Total: GBP 47.88")); + assert!(!output.contains("Subtotal:")); + assert!(!output.contains("Tax:")); + } + + #[test] + fn human_view_omits_completion_total_without_currency() { + let output = render_human(&human_response( + &json!({ + "id": "checkout-1", + "status": "completed", + "totals": [{"type": "total", "amount": 4788}] + }), + "idempotency-key", + &[], + )); + + assert!(!output.contains("Total:")); + assert!(!output.contains("4788")); + } +} diff --git a/rust/src/shopping/client.rs b/rust/src/shopping/client.rs index ab9c9c88..ae08f3ac 100644 --- a/rust/src/shopping/client.rs +++ b/rust/src/shopping/client.rs @@ -176,6 +176,7 @@ fn order_path(id: &str) -> String { mod tests { use httpmock::prelude::*; use serde_json::json; + use std::result::Result as TestResult; use super::*; @@ -183,6 +184,29 @@ mod tests { ShoppingClient::new(base_url, "test-token") } + fn assert_http_error( + error: ClientError, + expected_status: u16, + expected_body: &str, + expected_retry_after: Option, + ) -> TestResult<(), String> { + match error { + ClientError::Http { + status, + body, + retry_after, + } => { + assert_eq!(status, expected_status); + assert_eq!(body, expected_body); + assert_eq!(retry_after, expected_retry_after); + Ok(()) + } + ClientError::Network(error) => Err(format!( + "expected HTTP error, received network error: {error}" + )), + } + } + #[test] fn builds_shopping_paths_from_the_api_front_door() { assert_eq!( @@ -201,7 +225,266 @@ mod tests { } #[tokio::test] - async fn surfaces_order_not_found_as_retryable() { + async fn catalog_operations_send_expected_requests() { + let server = MockServer::start_async().await; + let search_request = json!({"query": "hosting"}); + let lookup_request = json!({"ids": ["web-hosting"]}); + let product_request = json!({"id": "web-hosting"}); + let search = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/shopping/catalog/search") + .header("authorization", "Bearer test-token") + .header_exists("x-request-id") + .json_body(search_request.clone()); + then.status(200).json_body(json!({"operation": "search"})); + }) + .await; + let lookup = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/shopping/catalog/lookup") + .header("authorization", "Bearer test-token") + .header_exists("x-request-id") + .json_body(lookup_request.clone()); + then.status(200).json_body(json!({"operation": "lookup"})); + }) + .await; + let product = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/shopping/catalog/product") + .header("authorization", "Bearer test-token") + .header_exists("x-request-id") + .json_body(product_request.clone()); + then.status(200).json_body(json!({"operation": "product"})); + }) + .await; + + let shopping = client(&server.base_url()); + assert_eq!( + shopping + .catalog_search(search_request) + .await + .expect("search")["operation"], + "search" + ); + assert_eq!( + shopping + .catalog_lookup(lookup_request) + .await + .expect("lookup")["operation"], + "lookup" + ); + assert_eq!( + shopping + .catalog_product(product_request) + .await + .expect("product")["operation"], + "product" + ); + + search.assert_async().await; + lookup.assert_async().await; + product.assert_async().await; + } + + #[tokio::test] + async fn checkout_lifecycle_uses_expected_methods_paths_and_bodies() { + let server = MockServer::start_async().await; + let checkout_request = json!({ + "line_items": [{"item": {"id": "variant-1"}, "quantity": 1}], + "payment": {"instruments": [{"id": "instrument-1", "selected": true}]} + }); + let completion_request = + json!({"payment": {"instruments": [{"id": "instrument-1", "selected": true}]}}); + let create = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/shopping/checkout-sessions") + .header("authorization", "Bearer test-token") + .header_exists("x-request-id") + .json_body(checkout_request.clone()); + then.status(201) + .json_body(json!({"id": "checkout-123", "operation": "create"})); + }) + .await; + let get = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/shopping/checkout-sessions/checkout-123") + .header("authorization", "Bearer test-token") + .header_exists("x-request-id"); + then.status(200) + .json_body(json!({"id": "checkout-123", "operation": "get"})); + }) + .await; + let update = server + .mock_async(|when, then| { + when.method(PUT) + .path("/v1/shopping/checkout-sessions/checkout-123") + .header("authorization", "Bearer test-token") + .header_exists("x-request-id") + .json_body(checkout_request.clone()); + then.status(200) + .json_body(json!({"id": "checkout-123", "operation": "update"})); + }) + .await; + let complete = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/shopping/checkout-sessions/checkout-123/complete") + .header("authorization", "Bearer test-token") + .header_exists("x-request-id") + .json_body(completion_request.clone()); + then.status(200) + .json_body(json!({"id": "checkout-123", "operation": "complete"})); + }) + .await; + + let shopping = client(&server.base_url()); + assert_eq!( + shopping + .create_checkout(checkout_request.clone()) + .await + .expect("create")["operation"], + "create" + ); + assert_eq!( + shopping.get_checkout("checkout-123").await.expect("get")["operation"], + "get" + ); + assert_eq!( + shopping + .update_checkout("checkout-123", checkout_request) + .await + .expect("update")["operation"], + "update" + ); + assert_eq!( + shopping + .complete_checkout("checkout-123", completion_request) + .await + .expect("complete")["operation"], + "complete" + ); + + create.assert_async().await; + get.assert_async().await; + update.assert_async().await; + complete.assert_async().await; + } + + #[tokio::test] + async fn order_read_sends_expected_request() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/shopping/orders/order-123") + .header("authorization", "Bearer test-token") + .header_exists("x-request-id"); + then.status(200).json_body(json!({"id": "order-123"})); + }) + .await; + + let order = client(&server.base_url()) + .get_order("order-123") + .await + .expect("get order"); + + mock.assert_async().await; + assert_eq!(order["id"], "order-123"); + } + + #[tokio::test] + async fn accepts_empty_success_responses() { + let server = MockServer::start_async().await; + let create = server + .mock_async(|when, then| { + when.method(POST).path("/v1/shopping/checkout-sessions"); + then.status(202).body(""); + }) + .await; + let complete = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/shopping/checkout-sessions/checkout-123/complete"); + then.status(204); + }) + .await; + + let shopping = client(&server.base_url()); + assert_eq!( + shopping + .create_checkout(json!({})) + .await + .expect("empty create"), + Value::Null + ); + assert_eq!( + shopping + .complete_checkout("checkout-123", json!({})) + .await + .expect("empty completion"), + Value::Null + ); + + create.assert_async().await; + complete.assert_async().await; + } + + #[tokio::test] + async fn encodes_dynamic_checkout_ids_on_the_wire() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/shopping/checkout-sessions/session%2Fa%3Fb%23c%25d") + .header("authorization", "Bearer test-token"); + then.status(200).json_body(json!({"id": "encoded"})); + }) + .await; + + let checkout = client(&server.base_url()) + .get_checkout("session/a?b#c%d") + .await + .expect("get encoded checkout"); + + mock.assert_async().await; + assert_eq!(checkout["id"], "encoded"); + } + + #[tokio::test] + async fn preserves_rate_limit_error_details_for_order_reads() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET).path("/v1/shopping/orders/order-123"); + then.status(429) + .header("retry-after", "7") + .body(r#"{"error":"rate_limited"}"#); + }) + .await; + + let error = client(&server.base_url()) + .get_order("order-123") + .await + .expect_err("429 is an error"); + + mock.assert_async().await; + assert!(error.is_retryable_order_read()); + assert_http_error( + error, + 429, + r#"{"error":"rate_limited"}"#, + Some(Duration::from_secs(7)), + ) + .expect("expected rate-limit HTTP error"); + } + + #[tokio::test] + async fn preserves_order_not_found_as_retryable() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { @@ -218,5 +501,86 @@ mod tests { mock.assert_async().await; assert!(error.is_retryable_order_read()); + assert_http_error(error, 404, r#"{"error":"order_not_found"}"#, None) + .expect("expected not-found HTTP error"); + } + + #[tokio::test] + async fn preserves_non_retryable_http_errors() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(PUT) + .path("/v1/shopping/checkout-sessions/checkout-123"); + then.status(400).body(r#"{"error":"invalid_checkout"}"#); + }) + .await; + + let error = client(&server.base_url()) + .update_checkout("checkout-123", json!({})) + .await + .expect_err("400 is an error"); + + mock.assert_async().await; + assert!(!error.is_retryable_order_read()); + assert_http_error(error, 400, r#"{"error":"invalid_checkout"}"#, None) + .expect("expected validation HTTP error"); + } + + #[tokio::test] + async fn preserves_empty_server_errors_and_retry_after() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET).path("/v1/shopping/orders/order-123"); + then.status(503).header("retry-after", "3"); + }) + .await; + + let error = client(&server.base_url()) + .get_order("order-123") + .await + .expect_err("503 is an error"); + + mock.assert_async().await; + assert!(error.is_retryable_order_read()); + assert_http_error(error, 503, "", Some(Duration::from_secs(3))) + .expect("expected server HTTP error"); + } + + #[tokio::test] + async fn reports_malformed_success_json() -> TestResult<(), String> { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST).path("/v1/shopping/catalog/search"); + then.status(200).body("not-json"); + }) + .await; + + let error = client(&server.base_url()) + .catalog_search(json!({})) + .await + .expect_err("malformed JSON is an error"); + + mock.assert_async().await; + match error { + ClientError::Http { + status, + body, + retry_after, + } => { + assert_eq!(status, 200); + assert!(body.contains("invalid JSON response")); + assert!(body.contains("not-json")); + assert_eq!(retry_after, None); + } + ClientError::Network(error) => { + return Err(format!( + "expected HTTP error, received network error: {error}" + )); + } + } + Ok(()) } } From 6afbe5802cc3f31cc3623de6fad7a03151467b10 Mon Sep 17 00:00:00 2001 From: sswaminathan Date: Fri, 11 Sep 2026 17:48:51 -0700 Subject: [PATCH 14/14] feat(shopping): refine customer purchase flow Co-Authored-By: Claude --- rust/Cargo.lock | 26 +- rust/Cargo.toml | 6 +- rust/api-catalog-sources.json | 5 + rust/shopping-client/Cargo.toml | 27 + rust/shopping-client/build.rs | 27 + .../openapi/shopping.oas3.json | 6660 +++++++++++++++++ rust/shopping-client/src/lib.rs | 43 + rust/src/domain/common.rs | 75 +- rust/src/domain/mod.rs | 2 +- rust/src/next_action.rs | 54 +- rust/src/shopping/catalog/get.rs | 47 +- rust/src/shopping/catalog/lookup.rs | 7 +- rust/src/shopping/catalog/mod.rs | 7 +- rust/src/shopping/catalog/search.rs | 353 +- rust/src/shopping/checkout/complete.rs | 184 +- rust/src/shopping/checkout/create.rs | 26 +- rust/src/shopping/checkout/get.rs | 132 +- rust/src/shopping/checkout/mod.rs | 7 +- rust/src/shopping/checkout/update.rs | 15 +- rust/src/shopping/client.rs | 392 +- rust/src/shopping/common.rs | 111 +- rust/src/shopping/guides/shopping.md | 254 +- rust/src/shopping/mod.rs | 21 +- rust/src/shopping/money.rs | 56 +- .../generate-api-catalog/src/dereference.rs | 83 +- rust/tools/generate-api-catalog/src/github.rs | 11 +- rust/tools/generate-api-catalog/src/main.rs | 11 +- .../generate-api-catalog/src/manifest.rs | 17 +- .../src/shopping_merge.rs | 342 + 29 files changed, 7816 insertions(+), 1185 deletions(-) create mode 100644 rust/shopping-client/Cargo.toml create mode 100644 rust/shopping-client/build.rs create mode 100644 rust/shopping-client/openapi/shopping.oas3.json create mode 100644 rust/shopping-client/src/lib.rs create mode 100644 rust/tools/generate-api-catalog/src/shopping_merge.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index cba5bb02..0bef421f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -544,9 +544,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli-engine" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2293555112db3ed0d4205f3068396ab86039cf39c73f3a76331cf2dd1ddd52ef" +checksum = "2fbcf040ad203e7c5749d2ed6ad979374009759663bd1c444081fc76fd2e20bf" dependencies = [ "async-trait", "base64", @@ -1363,6 +1363,7 @@ dependencies = [ "oxc_span", "oxc_syntax", "phonenumber", + "progenitor-client", "regex", "reqwest 0.13.4", "self-replace", @@ -1370,6 +1371,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "shopping-client", "tar", "tempfile", "thiserror", @@ -3645,6 +3647,26 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "shopping-client" +version = "0.2.12" +dependencies = [ + "bytes", + "futures", + "httpmock", + "openapiv3", + "prettyplease", + "progenitor", + "progenitor-client", + "reqwest 0.13.4", + "schemars 1.2.2", + "serde", + "serde_json", + "syn 2.0.119", + "thiserror", + "tokio", +] + [[package]] name = "signal-hook" version = "0.3.18" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4978212a..0d70c94e 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -6,7 +6,7 @@ description = "GoDaddy developer CLI" license = "Proprietary" [workspace] -members = [".", "tools/generate-api-catalog", "domains-client"] +members = [".", "tools/generate-api-catalog", "domains-client", "shopping-client"] [[bin]] name = "gddy" @@ -20,9 +20,10 @@ async-trait = "0.1" bytes = "1" chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } clap = { version = "4.5", features = ["std", "string"] } -cli-engine = { features = ["pkce-auth"], version = "0.9.3" } +cli-engine = { version = "0.9.5", features = ["pkce-auth"] } dirs = "6" domains-client = { path = "domains-client" } +shopping-client = { path = "shopping-client" } fancy-regex = "0.14" flate2 = { version = "1.1.9", default-features = false, features = ["rust_backend"] } globset = "0.4" @@ -34,6 +35,7 @@ oxc_parser = "0.143" oxc_span = "0.143" oxc_syntax = "0.143" phonenumber = "0.3" +progenitor-client = "0.14" regex = { version = "1", features = ["std"] } reqwest = { version = "0.13", default-features = false, features = ["json", "multipart", "form", "rustls"] } self-replace = "1.5.0" diff --git a/rust/api-catalog-sources.json b/rust/api-catalog-sources.json index fb94c053..9158c044 100644 --- a/rust/api-catalog-sources.json +++ b/rust/api-catalog-sources.json @@ -65,6 +65,11 @@ "domain": "shipping", "repository": "commerce.shipping-specification" }, + { + "domain": "shopping", + "repository": "shopping.ucp-specification", + "catalog": false + }, { "domain": "stores", "repository": "commerce.stores-specification" diff --git a/rust/shopping-client/Cargo.toml b/rust/shopping-client/Cargo.toml new file mode 100644 index 00000000..0f0d3edc --- /dev/null +++ b/rust/shopping-client/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "shopping-client" +version = "0.2.12" +edition = "2024" +license = "Proprietary" +description = "Generated GoDaddy Shopping API client, produced from the vendored OpenAPI 3.0 spec by progenitor at build time." + +[dependencies] +progenitor-client = "0.14" +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "stream"] } +schemars = { version = "1", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +futures = "0.3" +bytes = "1" +thiserror = "2" + +[build-dependencies] +progenitor = "0.14" +openapiv3 = "2" +serde_json = "1" +prettyplease = "0.2" +syn = "2" + +[dev-dependencies] +httpmock = "0.8" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } diff --git a/rust/shopping-client/build.rs b/rust/shopping-client/build.rs new file mode 100644 index 00000000..dc610130 --- /dev/null +++ b/rust/shopping-client/build.rs @@ -0,0 +1,27 @@ +//! Generates the typed Shopping API client from the vendored OpenAPI 3.0 spec. +//! +//! The committed `openapi/shopping.oas3.json` is regenerated by `cargo run -p +//! generate-api-catalog` from the canonical `gdcorp-platform/shopping.ucp-specification` +//! contract. This build step is hermetic and never accesses the network. + +use std::{env, fs, path::Path}; + +fn main() -> Result<(), Box> { + let spec_path = "openapi/shopping.oas3.json"; + println!("cargo:rerun-if-changed={spec_path}"); + println!("cargo:rerun-if-changed=build.rs"); + + let spec_text = fs::read_to_string(spec_path)?; + let spec: openapiv3::OpenAPI = serde_json::from_str(&spec_text)?; + let mut settings = progenitor::GenerationSettings::new(); + settings.with_interface(progenitor::InterfaceStyle::Builder); + settings.with_derive("schemars::JsonSchema"); + let mut generator = progenitor::Generator::new(&settings); + let tokens = generator.generate_tokens(&spec)?; + let ast = syn::parse2(tokens)?; + fs::write( + Path::new(&env::var("OUT_DIR")?).join("codegen.rs"), + prettyplease::unparse(&ast), + )?; + Ok(()) +} diff --git a/rust/shopping-client/openapi/shopping.oas3.json b/rust/shopping-client/openapi/shopping.oas3.json new file mode 100644 index 00000000..1d864a8f --- /dev/null +++ b/rust/shopping-client/openapi/shopping.oas3.json @@ -0,0 +1,6660 @@ +{ + "components": { + "examples": { + "checkout_complete_in_progress": { + "summary": "complete_checkout is in flight", + "value": { + "<<": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + }, + "payment": { + "instruments": [ + { + "display": { + "brand": "visa", + "last_digits": "4242" + }, + "handler_id": "com.godaddy.payments", + "id": "pi_1", + "selected": true, + "type": "card" + } + ] + }, + "status": "complete_in_progress" + } + }, + "checkout_completed": { + "summary": "Completed static Website Builder checkout", + "value": { + "<<": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + }, + "order": { + "id": "ord_5c21", + "permalink_url": "https://account.godaddy.com/orders/ord_5c21" + }, + "status": "completed" + } + }, + "checkout_incomplete": { + "summary": "Static Website Builder checkout incomplete for a non-payment requirement", + "value": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + } + }, + "checkout_invalid_explicit_payment": { + "summary": "Explicit saved payment profile is unavailable or ineligible", + "value": { + "messages": [ + { + "code": "payment_instrument_invalid", + "content": "The selected saved payment profile is unavailable or ineligible for this Checkout.", + "path": "$.payment.instruments[0].id", + "severity": "recoverable", + "type": "error" + } + ], + "ucp": { + "status": "error", + "version": "2026-04-08" + } + } + }, + "checkout_line_item_removed_pricing_failure": { + "summary": "One submitted item could not be priced and was removed", + "value": { + "<<": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + }, + "messages": [ + { + "code": "line_item_removed", + "content": "This item could not be priced and was removed from your order. The rest of your order is unaffected.", + "path": "$.line_items[1]", + "type": "warning" + } + ] + } + }, + "checkout_missing_required_input": { + "summary": "Generic target-state missing selected-variant configuration", + "value": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "messages": [ + { + "code": "missing_required_input", + "content": "Required selected-variant input is missing.", + "path": "$.line_items[0].input", + "severity": "recoverable", + "type": "error" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 0, + "type": "subtotal" + }, + { + "amount": 0, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + } + }, + "checkout_ready_for_complete": { + "summary": "Static Website Builder checkout ready to complete with the first eligible payment profile selected", + "value": { + "<<": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + }, + "payment": { + "instruments": [ + { + "display": { + "brand": "visa", + "last_digits": "4242" + }, + "handler_id": "com.godaddy.payments", + "id": "profile_visa", + "selected": true, + "type": "card" + }, + { + "display": { + "brand": "mastercard", + "last_digits": "4444" + }, + "handler_id": "com.godaddy.payments", + "id": "profile_mastercard", + "selected": false, + "type": "card" + } + ] + }, + "status": "ready_for_complete" + } + }, + "checkout_ready_for_complete_with_discount": { + "summary": "Checkout ready to complete with a dynamically computed discount", + "value": { + "<<": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + }, + "id": "chk_2b7e4", + "payment": { + "instruments": [ + { + "display": { + "brand": "visa", + "last_digits": "4242" + }, + "handler_id": "com.godaddy.payments", + "id": "pi_1", + "selected": true, + "type": "card" + } + ] + }, + "status": "ready_for_complete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": -100, + "lines": [ + { + "amount": -100, + "display_text": "Eligible promotion" + } + ], + "type": "discount" + }, + { + "amount": 899, + "type": "total" + } + ] + } + }, + "checkout_requires_escalation": { + "summary": "Checkout requiring an out-of-band saved payment method", + "value": { + "<<": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + }, + "continue_url": "https://account.godaddy.com/payment-methods/add-payment", + "messages": [ + { + "code": "payment_instrument_required", + "content": "No eligible saved payment method is available. Add one through the GoDaddy account payment-method page, then retrieve this Checkout again to refresh payment.instruments.", + "path": "$.payment", + "severity": "requires_buyer_input", + "type": "error" + } + ], + "payment": { + "instruments": [] + }, + "status": "requires_escalation" + } + }, + "checkout_requires_escalation_at_complete": { + "summary": "complete_checkout requires payment authentication", + "value": { + "<<": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + }, + "continue_url": "https://checkout.godaddy.com/3ds/chk_9f3a1", + "messages": [ + { + "code": "payment_authentication_required", + "content": "Your bank requires additional verification (3D Secure) to authorize this payment.", + "path": "$.payment", + "severity": "requires_buyer_review", + "type": "error" + } + ], + "payment": { + "instruments": [ + { + "display": { + "brand": "visa", + "last_digits": "4242" + }, + "handler_id": "com.godaddy.payments", + "id": "pi_1", + "selected": true, + "type": "card" + } + ] + }, + "status": "requires_escalation" + } + }, + "get_product_static_term_variants": { + "summary": "get_product response — source-backed static term variants", + "value": { + "product": { + "description": { + "plain": "cPanel Linux web hosting on the Deluxe plan, bundled with a Microsoft 365 email mailbox and GoDaddy Website Security (SSL included) on annual terms. The 1-month term is hosting only." + }, + "id": "nes-cpanel-set-2-deluxe-365-wss-xtra", + "price_range": { + "max": { + "amount": 1999, + "currency": "USD" + }, + "min": { + "amount": 799, + "currency": "USD" + } + }, + "title": "Web Hosting Deluxe", + "variants": [ + { + "availability": { + "available": true + }, + "description": { + "plain": "Deluxe web hosting on a 3-year term. Includes Microsoft 365 email and Website Security with SSL." + }, + "id": "nes-cpanel-set-2-deluxe-365-wss-xtra:3yr", + "included_products": [ + { + "description": { + "plain": "Deluxe cPanel Linux web hosting for up to 10 websites." + }, + "id": "hosting", + "price": { + "amount": 0, + "currency": "USD" + }, + "quantity": 1, + "title": "Web Hosting Deluxe" + }, + { + "description": { + "plain": "Three Microsoft 365 Email Essentials mailboxes included for 12 months." + }, + "id": "microsoft-365-email-essentials-trial", + "included_period": { + "count": 12, + "unit": "month" + }, + "list_price": { + "amount": 32364, + "currency": "USD" + }, + "price": { + "amount": 0, + "currency": "USD" + }, + "quantity": 3, + "tags": [ + "10 GB email storage", + "Email, calendar, and contacts synchronized across devices" + ], + "title": "Microsoft 365 Email Essentials Free Trial" + }, + { + "description": { + "plain": "Website Security Standard with SSL, WAF, CDN, malware removal, and a site seal." + }, + "id": "website-security-standard", + "list_price": { + "amount": 1298, + "currency": "USD" + }, + "price": { + "amount": 0, + "currency": "USD" + }, + "quantity": 1, + "tags": [ + "Web application firewall and CDN", + "Malware removal and site seal" + ], + "title": "Website Security Standard" + } + ], + "price": { + "amount": 799, + "currency": "USD" + }, + "title": "Web Hosting Deluxe — 3 Year" + }, + { + "availability": { + "available": true + }, + "description": { + "plain": "Deluxe web hosting on a 1-year term. Includes Microsoft 365 email and Website Security with SSL." + }, + "id": "nes-cpanel-set-2-deluxe-365-wss-xtra:1yr", + "price": { + "amount": 999, + "currency": "USD" + }, + "title": "Web Hosting Deluxe — 1 Year" + }, + { + "availability": { + "available": true + }, + "description": { + "plain": "Deluxe web hosting on a 2-year term. Includes Microsoft 365 email and Website Security with SSL." + }, + "id": "nes-cpanel-set-2-deluxe-365-wss-xtra:2yr", + "price": { + "amount": 899, + "currency": "USD" + }, + "title": "Web Hosting Deluxe — 2 Year" + }, + { + "availability": { + "available": true + }, + "description": { + "plain": "Deluxe web hosting on a 1-month term. Hosting only — Microsoft 365 email and Website Security are included on annual terms." + }, + "id": "nes-cpanel-set-2-deluxe-365-wss-xtra:1mo", + "price": { + "amount": 1999, + "currency": "USD" + }, + "title": "Web Hosting Deluxe — 1 Month" + } + ] + }, + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.catalog.lookup": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + } + }, + "order_example": { + "summary": "Completed order detail for the static Website Builder variant", + "value": { + "checkout_id": "chk_9f3a1", + "currency": "USD", + "fulfillment": { + "expectations": [ + { + "description": "Digital product fulfillment.", + "fulfillable_on": "now", + "id": "exp_1", + "line_items": [ + { + "id": "oli_1", + "quantity": 1 + } + ], + "method_type": "digital" + } + ] + }, + "id": "ord_5c21", + "line_items": [ + { + "id": "oli_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": { + "fulfilled": 1, + "total": 1 + }, + "status": "fulfilled", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "permalink_url": "https://account.godaddy.com/orders/ord_5c21", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + } + } + }, + "parameters": { + "CheckoutSessionId": { + "description": "The unique identifier of the checkout session.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + "IdempotencyKey": { + "description": "Ensures duplicate operations don't happen during retries.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + }, + "RequestId": { + "description": "For tracing the request across network layers and components.", + "in": "header", + "name": "Request-Id", + "required": true, + "schema": { + "type": "string" + } + }, + "UCPAgent": { + "description": "Identifies the UCP agent making the call. All requests MUST\ninclude the UCP-Agent header. Uses RFC 8941 Dictionary syntax,\ne.g. `profile=\"https://chatgpt.com/.well-known/ucp\"`, so the\nbusiness can fetch the platform's own profile for negotiation.\n", + "example": "profile=\"https://platform.example.com/.well-known/ucp\"", + "in": "header", + "name": "UCP-Agent", + "required": true, + "schema": { + "type": "string" + } + } + }, + "responses": { + "NotFound": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error_response" + } + } + }, + "description": "Resource does not exist." + }, + "RateLimited": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error_response" + } + } + }, + "description": "Too many requests." + }, + "Unauthorized": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error_response" + } + } + }, + "description": "Missing/invalid credentials (transport-level, not a UCP business-logic error)." + } + }, + "schemas": { + "adjustment": { + "$id": "https://ucp.dev/schemas/shopping/types/adjustment.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Post-order event that exists independently of fulfillment. Typically represents money movements but can be any post-order change. Polymorphic type that can optionally reference line items.", + "properties": { + "description": { + "description": "Human-readable reason or description (e.g., 'Defective item', 'Customer requested').", + "type": "string" + }, + "id": { + "description": "Adjustment event identifier.", + "type": "string" + }, + "line_items": { + "description": "Which line items and quantities are affected (optional).", + "items": { + "properties": { + "id": { + "description": "Line item ID reference.", + "type": "string" + }, + "quantity": { + "description": "Signed quantity affected by this adjustment. Negative values represent reductions (e.g. returns); positive values represent additions (e.g. exchanges).", + "type": "integer" + } + }, + "type": "object" + }, + "type": "array" + }, + "occurred_at": { + "description": "RFC 3339 timestamp when this adjustment occurred.", + "type": "string" + }, + "status": { + "description": "Adjustment status.", + "enum": [ + "pending", + "completed", + "failed" + ], + "type": "string" + }, + "totals": { + "description": "Adjustment totals breakdown. Signed values - negative for money returned to buyer (refunds, credits), positive for additional charges (exchanges).", + "items": { + "$ref": "#/components/schemas/total" + }, + "type": "array" + }, + "type": { + "description": "Type of adjustment (open string). Typically money-related like: refund, return, credit, price_adjustment, dispute, cancellation. Can be any value that makes sense for the merchant's business.", + "type": "string" + } + }, + "title": "Adjustment", + "type": "object" + }, + "amount": { + "$id": "https://ucp.dev/schemas/shopping/types/amount.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Monetary amount in the currency's minor unit as defined by ISO 4217. Refer to the currency's exponent to determine minor-to-major ratio (e.g., 2 for USD, 0 for JPY, 3 for KWD).", + "minimum": 0, + "title": "Amount", + "type": "integer" + }, + "attribution": { + "$id": "https://ucp.dev/schemas/shopping/types/attribution.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": { + "description": "URL-style parameter value, encoded as a string. Numeric or boolean values MUST be string-encoded as they would be in a URL query string.", + "type": "string" + }, + "description": "Platform-emitted referral and conversion-event context — campaign identifiers, click IDs, source/medium markers, etc. The same parameters platforms communicate via URL query parameters in browser-based flows.", + "title": "Attribution", + "type": "object" + }, + "available_payment_instrument": { + "$id": "https://ucp.dev/schemas/shopping/types/available_payment_instrument.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "An instrument type available from a payment handler with optional constraints.", + "properties": { + "constraints": { + "additionalProperties": true, + "description": "Constraints on this instrument type. Structure depends on instrument type and active capabilities.", + "minProperties": 1, + "type": "object" + }, + "type": { + "description": "The instrument type identifier (e.g., 'card', 'gift_card'). References an instrument schema's type constant.", + "type": "string" + } + }, + "title": "Available Payment Instrument", + "type": "object" + }, + "base_schema": { + "$id": "https://godaddy.com/ucp/schemas/product-metadata/base.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "description": "Optional, read-only public GoDaddy product metadata shared across product families. All properties are optional. Additional merchant-defined properties are permitted.", + "properties": { + "category": { + "description": "GoDaddy product category, when returned by the catalog.", + "type": "string" + }, + "plan": { + "$ref": "#/components/schemas/product-plan_schema" + }, + "product_family": { + "description": "Optional product-family classification. Known values are security and productivity; unknown values remain valid generic metadata.", + "minLength": 1, + "type": "string" + }, + "product_type": { + "description": "Optional GoDaddy product-type classification.", + "type": "string" + } + }, + "title": "GoDaddy base product metadata", + "type": "object" + }, + "business_fulfillment_config": { + "$id": "https://ucp.dev/schemas/shopping/types/business_fulfillment_config.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Business's fulfillment configuration.", + "properties": { + "allows_method_combinations": { + "description": "Allowed method type combinations.", + "items": { + "items": { + "enum": [ + "shipping", + "pickup" + ], + "type": "string" + }, + "type": "array" + }, + "type": "array" + }, + "allows_multi_destination": { + "additionalProperties": false, + "description": "Permits multiple destinations per method type.", + "properties": { + "pickup": { + "description": "Multiple pickup locations allowed.", + "type": "boolean" + }, + "shipping": { + "description": "Multiple shipping destinations allowed.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "title": "Business Fulfillment Config", + "type": "object" + }, + "buyer": { + "$id": "https://ucp.dev/schemas/shopping/types/buyer.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "properties": { + "email": { + "description": "Email of the buyer.", + "type": "string" + }, + "first_name": { + "description": "First name of the buyer.", + "type": "string" + }, + "last_name": { + "description": "Last name of the buyer.", + "type": "string" + }, + "phone_number": { + "description": "E.164 standard.", + "type": "string" + } + }, + "title": "Buyer", + "type": "object" + }, + "capability_base": { + "allOf": [ + { + "$ref": "#/components/schemas/ucp_entity" + }, + { + "properties": { + "extends": { + "description": "Parent capability(s) this extends. Present for extensions, absent for root capabilities. Use array for multi-parent extensions.", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + ] + } + }, + "type": "object" + } + ] + }, + "capability_business_schema": { + "allOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/ucp_entity" + }, + { + "properties": { + "extends": { + "description": "Parent capability(s) this extends. Present for extensions, absent for root capabilities. Use array for multi-parent extensions.", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + ] + } + }, + "type": "object" + } + ] + } + ], + "description": "Capability configuration for business/merchant level. May include business-specific config overrides.", + "title": "Capability (Business Schema)" + }, + "capability_platform_schema": { + "allOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/ucp_entity" + }, + { + "properties": { + "extends": { + "description": "Parent capability(s) this extends. Present for extensions, absent for root capabilities. Use array for multi-parent extensions.", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + ] + } + }, + "type": "object" + } + ] + }, + {} + ], + "description": "Full capability declaration for platform-level discovery. Includes spec/schema URLs for agent fetching.", + "title": "Capability (Platform Schema)" + }, + "capability_response_schema": { + "allOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/ucp_entity" + }, + { + "properties": { + "extends": { + "description": "Parent capability(s) this extends. Present for extensions, absent for root capabilities. Use array for multi-parent extensions.", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + ] + } + }, + "type": "object" + } + ] + } + ], + "description": "Capability reference in responses. Only name/version required to confirm active capabilities.", + "title": "Capability (Response Schema)" + }, + "catalog-offer-included-product_schema": { + "$defs": { + "period": { + "additionalProperties": false, + "properties": { + "count": { + "minimum": 1, + "type": "integer" + }, + "unit": { + "description": "Duration unit. Known values: day, month, year, onetime.", + "examples": [ + "day", + "month", + "year", + "onetime" + ], + "type": "string" + } + }, + "type": "object" + } + }, + "$id": "https://godaddy.com/ucp/schemas/catalog-offer-included-product.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "$ref": "#/components/schemas/ucp-refs_schema_variant" + }, + { + "properties": { + "included_period": { + "additionalProperties": false, + "properties": { + "count": { + "minimum": 1, + "type": "integer" + }, + "unit": { + "description": "Duration unit. Known values: day, month, year, onetime.", + "examples": [ + "day", + "month", + "year", + "onetime" + ], + "type": "string" + } + }, + "type": "object" + }, + "quantity": { + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + } + ], + "description": "Read-only customer-visible UCP Variant included by a catalog-offer Variant. Catalog responses describe the expected composition; Checkout responses describe the resolved composition. Included products are not independently selected, editable, removable, or included in Checkout totals a second time. A prior `not` constraint that prohibited the `included`, `standard_price`, and `included_until` fields was removed: those fields had no basis in the UCP Variant contract or the catalog-offer extension policy, making the constraint an unintended restriction rather than a deliberate rule.", + "title": "Catalog offer included product" + }, + "catalog_lookup_get_product_request": { + "description": "Request body for single-product retrieval. Supports interactive variant narrowing via selected and preferences.", + "properties": { + "attribution": { + "$ref": "#/components/schemas/attribution" + }, + "context": { + "$ref": "#/components/schemas/context" + }, + "filters": { + "$ref": "#/components/schemas/search_filters" + }, + "id": { + "description": "Product or variant identifier. Implementations MUST support product ID and variant ID.", + "type": "string" + }, + "preferences": { + "description": "Option names in relaxation priority order. When no exact variant matches all selections, the server drops options from the end of this list first. E.g., ['Color', 'Size'] keeps Color and relaxes Size.", + "items": { + "type": "string" + }, + "type": "array" + }, + "selected": { + "description": "Partial or full option selections for interactive variant narrowing. When provided, response option values include availability signals (available, exists) relative to these selections.", + "items": { + "$ref": "#/components/schemas/selected_option" + }, + "type": "array" + }, + "signals": { + "$ref": "#/components/schemas/signals" + } + }, + "type": "object" + }, + "catalog_lookup_get_product_response": { + "properties": { + "messages": { + "description": "Warnings or informational messages about the product (e.g., price recently changed, limited availability).", + "items": { + "$ref": "#/components/schemas/message" + }, + "type": "array" + }, + "product": { + "allOf": [ + { + "$ref": "#/components/schemas/product" + } + ], + "description": "A product in a get_product response, extended with effective selections and availability signals on option values.", + "properties": { + "options": { + "description": "Product options with availability signals relative to the effective selections.", + "items": { + "properties": { + "name": { + "type": "string" + }, + "values": { + "items": { + "$ref": "#/components/schemas/detail_option_value" + }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "selected": { + "description": "Effective option selections that anchor the featured variant and availability signals. Required when the product has configurable options; may be empty or omitted for products with no option axes.", + "items": { + "$ref": "#/components/schemas/selected_option" + }, + "type": "array" + } + }, + "type": "object" + }, + "ucp": { + "$ref": "#/components/schemas/ucp_response_catalog_schema" + } + }, + "type": "object" + }, + "catalog_lookup_lookup_request": { + "description": "Request body for catalog lookup.", + "properties": { + "attribution": { + "$ref": "#/components/schemas/attribution" + }, + "context": { + "$ref": "#/components/schemas/context" + }, + "filters": { + "$ref": "#/components/schemas/search_filters" + }, + "ids": { + "description": "Identifiers to lookup. Implementations MUST support product ID and variant ID; MAY support secondary identifiers (SKU, handle, etc.).", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "signals": { + "$ref": "#/components/schemas/signals" + } + }, + "type": "object" + }, + "catalog_lookup_lookup_response": { + "properties": { + "messages": { + "description": "Errors, warnings, or informational messages about the requested items.", + "items": { + "$ref": "#/components/schemas/message" + }, + "type": "array" + }, + "products": { + "description": "Products matching the requested identifiers. May contain fewer items if some identifiers not found, or more if identifiers match multiple products.", + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/product" + }, + { + "properties": { + "variants": { + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/variant" + }, + { + "properties": { + "inputs": { + "description": "Which request identifiers resolved to this variant, and how. Each entry maps a request ID to its match type.", + "items": { + "$ref": "#/components/schemas/input_correlation" + }, + "minItems": 1, + "type": "array" + } + } + } + ], + "description": "Variant with required correlation metadata for lookup responses." + } + } + } + } + ] + }, + "type": "array" + }, + "ucp": { + "$ref": "#/components/schemas/ucp_response_catalog_schema" + } + }, + "type": "object" + }, + "catalog_search_search_request": { + "properties": { + "attribution": { + "$ref": "#/components/schemas/attribution" + }, + "context": { + "$ref": "#/components/schemas/context" + }, + "domains": { + "$ref": "#/components/schemas/domain-discovery-search-request_schema_domains" + }, + "filters": { + "$ref": "#/components/schemas/search_filters" + }, + "pagination": { + "$ref": "#/components/schemas/pagination_request" + }, + "query": { + "description": "Free-text search query.", + "type": "string" + }, + "signals": { + "$ref": "#/components/schemas/signals" + } + }, + "type": "object" + }, + "catalog_search_search_response": { + "properties": { + "messages": { + "description": "Errors, warnings, or informational messages about the search results.", + "items": { + "$ref": "#/components/schemas/message" + }, + "type": "array" + }, + "pagination": { + "$ref": "#/components/schemas/pagination_response" + }, + "products": { + "description": "Products matching the search criteria.", + "items": { + "$ref": "#/components/schemas/product" + }, + "type": "array" + }, + "ucp": { + "$ref": "#/components/schemas/ucp_response_catalog_schema" + } + }, + "type": "object" + }, + "category": { + "$id": "https://ucp.dev/schemas/shopping/types/category.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A product category with optional taxonomy identifier.", + "properties": { + "taxonomy": { + "description": "Source taxonomy. Well-known values: `google_product_category`, `shopify`, `merchant`.", + "type": "string" + }, + "value": { + "description": "Category value or path (e.g., 'Apparel > Shirts', '1604').", + "type": "string" + } + }, + "title": "Category", + "type": "object" + }, + "checkout": { + "$id": "https://ucp.dev/schemas/shopping/checkout.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "description": "Base checkout schema. Extensions compose onto this using allOf.", + "name": "dev.ucp.shopping.checkout", + "properties": { + "attribution": { + "$ref": "#/components/schemas/attribution" + }, + "buyer": { + "$ref": "#/components/schemas/buyer" + }, + "context": { + "$ref": "#/components/schemas/context" + }, + "continue_url": { + "description": "URL for checkout handoff and session recovery. MUST be provided when status is requires_escalation. See specification for format and availability requirements.", + "type": "string", + "ucp_request": "omit" + }, + "currency": { + "description": "ISO 4217 currency code reflecting the merchant's market determination. Derived from address, context, and geo IP—buyers provide signals, merchants determine currency.", + "type": "string", + "ucp_request": "omit" + }, + "expires_at": { + "description": "RFC 3339 expiry timestamp. Default TTL is 6 hours from creation if not sent.", + "type": "string", + "ucp_request": "omit" + }, + "fulfillment": { + "$ref": "#/components/schemas/fulfillment" + }, + "id": { + "description": "Unique identifier of the checkout session.", + "type": "string", + "ucp_request": "omit" + }, + "line_items": { + "description": "List of line items being checked out.", + "items": { + "$ref": "#/components/schemas/line_item" + }, + "type": "array", + "ucp_request": { + "complete": "omit", + "create": "required", + "update": "required" + } + }, + "links": { + "description": "Links to be displayed by the platform (Privacy Policy, TOS). Mandatory for legal compliance.", + "items": { + "$ref": "#/components/schemas/link" + }, + "type": "array", + "ucp_request": "omit" + }, + "messages": { + "description": "List of messages with error and info about the checkout session state.", + "items": { + "$ref": "#/components/schemas/message" + }, + "type": "array", + "ucp_request": "omit" + }, + "order": { + "$ref": "#/components/schemas/order_confirmation" + }, + "payment": { + "$ref": "#/components/schemas/payment" + }, + "signals": { + "$ref": "#/components/schemas/signals" + }, + "status": { + "description": "Checkout state machine status. Known values: incomplete, requires_escalation, ready_for_complete, complete_in_progress, completed, canceled.", + "examples": [ + "incomplete", + "requires_escalation", + "ready_for_complete", + "complete_in_progress", + "completed", + "canceled" + ], + "type": "string", + "ucp_request": "omit" + }, + "totals": { + "$ref": "#/components/schemas/totals" + }, + "ucp": { + "$ref": "#/components/schemas/ucp_response_checkout_schema" + } + }, + "title": "Checkout", + "type": "object" + }, + "checkout-complete-request_schema": { + "$id": "https://godaddy.com/ucp/schemas/requests/checkout-complete-request.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "name": "dev.ucp.shopping.checkout", + "properties": { + "attribution": { + "$ref": "#/components/schemas/ucp-refs_schema_attribution" + }, + "idempotency_key": { + "type": "string" + }, + "payment": { + "$ref": "#/components/schemas/ucp-refs_schema_payment" + }, + "signals": { + "$ref": "#/components/schemas/ucp-refs_schema_signals" + } + }, + "title": "Checkout Complete request", + "type": "object" + }, + "checkout-writable-request_schema": { + "$id": "https://godaddy.com/ucp/schemas/requests/checkout-writable-request.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "description": "Shared UCP 2026-04-08 writable Checkout fields for create and update requests.", + "properties": { + "attribution": { + "$ref": "#/components/schemas/ucp-refs_schema_attribution" + }, + "buyer": { + "$ref": "#/components/schemas/ucp-refs_schema_buyer" + }, + "context": { + "$ref": "#/components/schemas/ucp-refs_schema_context" + }, + "fulfillment": { + "$ref": "#/components/schemas/ucp-refs_schema_fulfillment" + }, + "line_items": { + "description": "List of line items being checked out.", + "items": { + "$ref": "#/components/schemas/ucp-refs_schema_line_item" + }, + "type": "array" + }, + "payment": { + "$ref": "#/components/schemas/ucp-refs_schema_payment" + }, + "signals": { + "$ref": "#/components/schemas/ucp-refs_schema_signals" + } + }, + "title": "Checkout writable request fields", + "type": "object" + }, + "checkout_complete_request": { + "$ref": "#/components/schemas/checkout-complete-request_schema" + }, + "checkout_writable_request": { + "$ref": "#/components/schemas/checkout-writable-request_schema" + }, + "com_godaddy_shopping_catalog_action_schema_action_required": { + "properties": { + "capability": { + "description": "Negotiated capability that defines the operation. Standard operations use their dev.ucp.* capability; custom operations use the vendor capability declaring the operation's REST, MCP, A2A, or other negotiated service binding.", + "minLength": 1, + "type": "string" + }, + "hint": { + "description": "Required audience-neutral, non-normative guidance. It must not add requirements absent from input_schema.", + "minLength": 1, + "type": "string" + }, + "input_schema": { + "$ref": "#/components/schemas/schema" + }, + "operation_id": { + "description": "Operation identifier in the negotiated capability's service binding.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "com_godaddy_shopping_input_schema_input_schema": { + "$ref": "#/components/schemas/schema" + }, + "com_godaddy_shopping_input_schema_input_value": { + "description": "A submitted line-item value. Object-shaped purchase input MUST include the root type discriminator required by its selected variant schema. The Checkout application validates the value against that schema during create and update.", + "if": { + "type": "object" + }, + "then": { + "properties": { + "type": { + "minLength": 1, + "type": "string" + } + } + } + }, + "context": { + "$id": "https://ucp.dev/schemas/shopping/types/context.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "description": "Provisional buyer signals for relevance and localization—not authoritative data. Businesses SHOULD use these values when verified inputs (e.g., shipping address) are absent, and MAY ignore or down-rank them if inconsistent with higher-confidence signals (authenticated account, risk detection) or regulatory constraints (export controls). Eligibility and policy enforcement MUST occur at checkout time using binding transaction data. Context SHOULD be non-identifying and can be disclosed progressively—coarse signals early, finer resolution as the session progresses. Higher-resolution data (shipping address, billing address) supersedes context.", + "properties": { + "address_country": { + "description": "The country. Recommended to be in 2-letter ISO 3166-1 alpha-2 format, for example \"US\". For backward compatibility, a 3-letter ISO 3166-1 alpha-3 country code such as \"SGP\" or a full country name such as \"Singapore\" can also be used. Optional hint for market context (currency, availability, pricing)—higher-resolution data (e.g., shipping address) supersedes this value.", + "type": "string" + }, + "address_region": { + "description": "The region in which the locality is, and which is in the country. For example, California or another appropriate first-level Administrative division. Optional hint for progressive localization—higher-resolution data (e.g., shipping address) supersedes this value.", + "type": "string" + }, + "currency": { + "description": "Preferred currency (ISO 4217, e.g., 'EUR', 'USD'). Businesses determine presentment currency from context and authoritative signals; this hint MAY inform selection in multi-currency markets. Also serves as the denomination for price filter values — platforms SHOULD include this field when sending price filters. Response prices include explicit currency confirming the resolution.", + "type": "string" + }, + "eligibility": { + "description": "Buyer claims about eligible benefits such as loyalty membership, payment instrument perks, and similar. Recognized claims MAY inform the Business response (e.g., member-only product availability, adjusted pricing in catalog, provisional discounts at cart or checkout). Businesses MUST ignore unrecognized values without error. Values MUST use reverse-domain naming (e.g., 'com.example.loyalty_gold', 'org.school.student') and MUST be non-identifying.", + "items": { + "$ref": "#/components/schemas/reverse_domain_name" + }, + "type": "array", + "uniqueItems": true + }, + "intent": { + "description": "Background context describing buyer's intent (e.g., 'looking for a gift under $50', 'need something durable for outdoor use'). Informs relevance, recommendations, and personalization.", + "type": "string" + }, + "language": { + "description": "Preferred language for content. Use IETF BCP 47 language tags (e.g., 'en', 'fr-CA', 'zh-Hans'). For REST, equivalent to Accept-Language header—platforms SHOULD fall back to Accept-Language when this field is absent; when provided, overrides Accept-Language. Businesses MAY return content in a different language if unavailable.", + "type": "string" + }, + "postal_code": { + "description": "The postal code. For example, 94043. Optional hint for regional refinement—higher-resolution data (e.g., shipping address) supersedes this value.", + "type": "string" + } + }, + "title": "Context", + "type": "object" + }, + "description": { + "$id": "https://ucp.dev/schemas/shopping/types/description.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Description content in one or more formats. At least one format must be provided.", + "minProperties": 1, + "properties": { + "html": { + "description": "HTML-formatted content. Security: Platforms MUST sanitize before rendering—strip scripts, event handlers, and untrusted elements. Treat all rich text as untrusted input.", + "type": "string" + }, + "markdown": { + "description": "Markdown-formatted content.", + "type": "string" + }, + "plain": { + "description": "Plain text content.", + "type": "string" + } + }, + "title": "Description", + "type": "object" + }, + "detail_option_value": { + "$id": "https://ucp.dev/schemas/shopping/types/detail_option_value.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "$ref": "#/components/schemas/option_value" + } + ], + "description": "An option value with availability signals relative to the current selections. Used in get_product responses where selected context exists.", + "properties": { + "available": { + "description": "Whether a variant matching this value and the current option selections is purchasable.", + "type": "boolean" + }, + "exists": { + "description": "Whether a variant matching this value and the current option selections exists in the catalog.", + "type": "boolean" + } + }, + "title": "Detail Option Value", + "type": "object" + }, + "domain-discovery-search-request_schema": { + "$id": "https://godaddy.com/ucp/schemas/domain-discovery-search-request.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Domains-only search_catalog request for the com.godaddy.shopping.domain action. Requires one to 100 explicit candidate FQDNs. Native UCP Catalog Search fields (context, signals, attribution, filters, pagination) are permitted alongside domains.", + "properties": { + "domains": { + "description": "One to 100 ASCII DNS-style candidate FQDNs. Internationalized names MUST use ASCII-compatible (punycode) encoding; trailing dots are not accepted.", + "items": { + "maxLength": 253, + "type": "string" + }, + "maxItems": 100, + "minItems": 1, + "type": "array" + } + }, + "title": "Domain discovery Catalog Search request", + "type": "object" + }, + "domain-discovery-search-request_schema_domains": { + "description": "One to 100 ASCII DNS-style candidate FQDNs. Internationalized names MUST use ASCII-compatible (punycode) encoding; trailing dots are not accepted.", + "items": { + "maxLength": 253, + "type": "string" + }, + "maxItems": 100, + "minItems": 1, + "type": "array" + }, + "email_schema": { + "$id": "https://godaddy.com/ucp/schemas/product-metadata/email.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "$ref": "#/components/schemas/base_schema" + }, + { + "additionalProperties": true, + "properties": { + "mailbox_size_mb": { + "description": "Mailbox capacity in megabytes.", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + } + ], + "description": "Optional, read-only product metadata fields specific to email offerings. Additional merchant-defined properties are permitted.", + "title": "GoDaddy email product metadata" + }, + "embedded_config": { + "$id": "https://ucp.dev/schemas/transports/embedded_config.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Per-session configuration for embedded transport binding. Allows businesses to vary EP availability and delegations based on cart contents, agent authorization, or policy.", + "properties": { + "color_scheme": { + "description": "Color schemes the business supports. Hosts use ec_color_scheme query parameter to request a scheme from this list.", + "items": { + "enum": [ + "light", + "dark" + ], + "type": "string" + }, + "type": "array" + }, + "delegate": { + "description": "Delegations the business allows. At service-level, declares available delegations. In UCP responses, confirms accepted delegations for this session.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "title": "Embedded Transport Config", + "type": "object" + }, + "error_code": { + "$id": "https://ucp.dev/schemas/shopping/types/error_code.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Error code identifying the type of error. Standard errors are defined in specification (see examples), and have standardized semantics; freeform codes are permitted.", + "examples": [ + "not_found", + "out_of_stock", + "item_unavailable", + "address_undeliverable", + "payment_failed", + "eligibility_invalid", + "identity_required", + "insufficient_scope" + ], + "title": "Error Code", + "type": "string" + }, + "error_response": {}, + "expectation": { + "$id": "https://ucp.dev/schemas/shopping/types/expectation.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Buyer-facing fulfillment expectation representing logical groupings of items (e.g., 'package'). Can be split, merged, or adjusted post-order to set buyer expectations for when/how items arrive.", + "properties": { + "description": { + "description": "Human-readable delivery description (e.g., 'Arrives in 5-8 business days').", + "type": "string" + }, + "destination": { + "$ref": "#/components/schemas/postal_address" + }, + "fulfillable_on": { + "description": "When this expectation can be fulfilled: 'now' or ISO 8601 timestamp for future date (backorder, pre-order).", + "type": "string" + }, + "id": { + "description": "Expectation identifier.", + "type": "string" + }, + "line_items": { + "description": "Which line items and quantities are in this expectation.", + "items": { + "properties": { + "id": { + "description": "Line item ID reference.", + "type": "string" + }, + "quantity": { + "description": "Quantity of this item in this expectation.", + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "type": "array" + }, + "method_type": { + "description": "Delivery method type (shipping, pickup, digital).", + "enum": [ + "shipping", + "pickup", + "digital" + ], + "type": "string" + } + }, + "title": "Expectation", + "type": "object" + }, + "fulfillment": { + "$defs": { + "dev.ucp.shopping.checkout": { + "allOf": [ + { + "$ref": "#/components/schemas/checkout" + }, + { + "properties": { + "fulfillment": { + "$ref": "#/components/schemas/fulfillment" + } + }, + "type": "object" + } + ], + "description": "Checkout extended with hierarchical fulfillment.", + "title": "Checkout with Fulfillment" + }, + "dev.ucp.shopping.fulfillment": { + "business_schema": { + "allOf": [ + { + "$ref": "#/components/schemas/capability_business_schema" + }, + { + "properties": { + "config": { + "$ref": "#/components/schemas/business_fulfillment_config" + } + } + } + ], + "description": "Business-level fulfillment capability configuration", + "title": "Fulfillment Capability (Business)" + }, + "platform_schema": { + "allOf": [ + { + "$ref": "#/components/schemas/capability_platform_schema" + }, + { + "properties": { + "config": { + "$ref": "#/components/schemas/platform_fulfillment_config" + } + } + } + ], + "description": "Platform-level fulfillment capability configuration", + "title": "Fulfillment Capability (Platform)" + } + }, + "fulfillment": { + "$ref": "#/components/schemas/fulfillment" + }, + "fulfillment_available_method": { + "$ref": "#/components/schemas/fulfillment_available_method" + }, + "fulfillment_group": { + "$ref": "#/components/schemas/fulfillment_group" + }, + "fulfillment_method": { + "$ref": "#/components/schemas/fulfillment_method" + }, + "fulfillment_option": { + "$ref": "#/components/schemas/fulfillment_option" + } + }, + "$id": "https://ucp.dev/schemas/shopping/fulfillment.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Extends Checkout with fulfillment support using methods, destinations, and groups.", + "embedded": { + "delegations": [ + "fulfillment.address_change" + ], + "methods": { + "ec.fulfillment.address_change_request": { + "description": "Merchant requests host to present address selection UI for a shipping fulfillment method.", + "name": "ec.fulfillment.address_change_request", + "params": [ + { + "name": "checkout", + "schema": { + "$ref": "#/components/schemas/checkout" + } + } + ], + "result": { + "name": "addressChangeResult", + "schema": { + "oneOf": [ + { + "description": "Checkout state after address selection.", + "properties": { + "checkout": { + "description": "Partial checkout update with fulfillment address selection.", + "properties": { + "fulfillment": { + "$ref": "#/components/schemas/fulfillment" + } + }, + "type": "object" + }, + "ucp": { + "$ref": "#/components/schemas/ucp_success" + } + }, + "type": "object" + }, + { + "$ref": "#/components/schemas/error_response" + } + ] + } + }, + "summary": "Request address change" + }, + "ec.fulfillment.change": { + "description": "Merchant notifies host that checkout.fulfillment has changed (shipping method selected, delivery options updated).", + "name": "ec.fulfillment.change", + "params": [ + { + "name": "checkout", + "schema": { + "$ref": "#/components/schemas/checkout" + } + } + ], + "summary": "Fulfillment details changed" + } + } + }, + "name": "dev.ucp.shopping.fulfillment", + "title": "Fulfillment Extension" + }, + "fulfillment_available_method": { + "$id": "https://ucp.dev/schemas/shopping/types/fulfillment_available_method.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "description": "Inventory availability hint for a fulfillment method type.", + "properties": { + "description": { + "description": "Human-readable availability info (e.g., 'Available for pickup at Downtown Store today').", + "type": "string", + "ucp_request": "omit" + }, + "fulfillable_on": { + "description": "'now' for immediate availability, or ISO 8601 date for future (preorders, transfers).", + "type": "string", + "ucp_request": "omit" + }, + "line_item_ids": { + "description": "Line items available for this fulfillment method.", + "items": { + "type": "string" + }, + "type": "array", + "ucp_request": "omit" + }, + "type": { + "description": "Fulfillment method type this availability applies to.", + "enum": [ + "shipping", + "pickup" + ], + "type": "string", + "ucp_request": "omit" + } + }, + "title": "Fulfillment Available Method", + "type": "object", + "ucp_shared_request": true + }, + "fulfillment_destination": { + "$id": "https://ucp.dev/schemas/shopping/types/fulfillment_destination.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A destination for fulfillment.", + "oneOf": [ + { + "$ref": "#/components/schemas/shipping_destination" + }, + { + "$ref": "#/components/schemas/retail_location" + } + ], + "title": "Fulfillment Destination", + "type": "object", + "ucp_shared_request": true + }, + "fulfillment_event": { + "$id": "https://ucp.dev/schemas/shopping/types/fulfillment_event.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Append-only fulfillment event representing an actual shipment. References line items by ID.", + "properties": { + "carrier": { + "description": "Carrier name (e.g., 'FedEx', 'USPS').", + "type": "string" + }, + "description": { + "description": "Human-readable description of the shipment status or delivery information (e.g., 'Delivered to front door', 'Out for delivery').", + "type": "string" + }, + "id": { + "description": "Fulfillment event identifier.", + "type": "string" + }, + "line_items": { + "description": "Which line items and quantities are fulfilled in this event.", + "items": { + "properties": { + "id": { + "description": "Line item ID reference.", + "type": "string" + }, + "quantity": { + "description": "Quantity fulfilled in this event.", + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "type": "array" + }, + "occurred_at": { + "description": "RFC 3339 timestamp when this fulfillment event occurred.", + "type": "string" + }, + "tracking_number": { + "description": "Carrier tracking number (required if type != processing).", + "type": "string" + }, + "tracking_url": { + "description": "URL to track this shipment (required if type != processing).", + "type": "string" + }, + "type": { + "description": "Fulfillment event type. Common values include: processing (preparing to ship), shipped (handed to carrier), in_transit (in delivery network), delivered (received by buyer), failed_attempt (delivery attempt failed), canceled (fulfillment canceled), undeliverable (cannot be delivered), returned_to_sender (returned to merchant).", + "type": "string" + } + }, + "title": "Fulfillment Event", + "type": "object" + }, + "fulfillment_group": { + "$id": "https://ucp.dev/schemas/shopping/types/fulfillment_group.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "description": "A merchant-generated package/group of line items with fulfillment options.", + "properties": { + "id": { + "description": "Group identifier for referencing merchant-generated groups in updates.", + "type": "string", + "ucp_request": { + "create": "omit", + "update": "required" + } + }, + "line_item_ids": { + "description": "Line item IDs included in this group/package.", + "items": { + "type": "string" + }, + "type": "array", + "ucp_request": "omit" + }, + "options": { + "description": "Available fulfillment options for this group.", + "items": { + "$ref": "#/components/schemas/fulfillment_option" + }, + "type": "array", + "ucp_request": "omit" + }, + "selected_option_id": { + "description": "ID of the selected fulfillment option for this group.", + "type": "string" + } + }, + "title": "Fulfillment Group", + "type": "object" + }, + "fulfillment_method": { + "$id": "https://ucp.dev/schemas/shopping/types/fulfillment_method.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "description": "A fulfillment method (shipping or pickup) with destinations and groups.", + "properties": { + "destinations": { + "description": "Available destinations. For shipping: addresses. For pickup: retail locations.", + "items": { + "$ref": "#/components/schemas/fulfillment_destination" + }, + "type": "array" + }, + "groups": { + "description": "Fulfillment groups for selecting options. Agent sets selected_option_id on groups to choose shipping method.", + "items": { + "$ref": "#/components/schemas/fulfillment_group" + }, + "type": "array" + }, + "id": { + "description": "Unique fulfillment method identifier.", + "type": "string", + "ucp_request": { + "create": "omit", + "update": "optional" + } + }, + "line_item_ids": { + "description": "Line item IDs fulfilled via this method.", + "items": { + "type": "string" + }, + "type": "array", + "ucp_request": { + "create": "optional", + "update": "required" + } + }, + "selected_destination_id": { + "description": "ID of the selected destination.", + "type": "string" + }, + "type": { + "description": "Fulfillment method type.", + "enum": [ + "shipping", + "pickup" + ], + "type": "string", + "ucp_request": { + "create": "required", + "update": "optional" + } + } + }, + "title": "Fulfillment Method", + "type": "object" + }, + "fulfillment_option": { + "$id": "https://ucp.dev/schemas/shopping/types/fulfillment_option.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "description": "A fulfillment option within a group (e.g., Standard Shipping $5, Express $15).", + "properties": { + "carrier": { + "description": "Carrier name (for shipping).", + "type": "string", + "ucp_request": "omit" + }, + "description": { + "description": "Complete context for buyer decision (e.g., 'Arrives Dec 12-15 via FedEx').", + "type": "string", + "ucp_request": "omit" + }, + "earliest_fulfillment_time": { + "description": "Earliest fulfillment date.", + "type": "string", + "ucp_request": "omit" + }, + "id": { + "description": "Unique fulfillment option identifier.", + "type": "string", + "ucp_request": "omit" + }, + "latest_fulfillment_time": { + "description": "Latest fulfillment date.", + "type": "string", + "ucp_request": "omit" + }, + "title": { + "description": "Short label (e.g., 'Express Shipping', 'Curbside Pickup').", + "type": "string", + "ucp_request": "omit" + }, + "totals": { + "description": "Fulfillment option totals breakdown.", + "items": { + "$ref": "#/components/schemas/total" + }, + "type": "array", + "ucp_request": "omit" + } + }, + "title": "Fulfillment Option", + "type": "object", + "ucp_shared_request": true + }, + "get_product_request": { + "$ref": "#/components/schemas/catalog_lookup_get_product_request" + }, + "get_product_response": { + "$ref": "#/components/schemas/catalog_lookup_get_product_response" + }, + "hosting_schema": { + "$id": "https://godaddy.com/ucp/schemas/product-metadata/hosting.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "$ref": "#/components/schemas/base_schema" + }, + { + "additionalProperties": true, + "properties": { + "control_panel": { + "description": "Hosting control panel product or interface.", + "type": "string" + }, + "performance_tier": { + "description": "Hosting performance tier.", + "type": "string" + }, + "storage_gb": { + "description": "Included storage capacity in gigabytes.", + "minimum": 0, + "type": "integer" + }, + "websites": { + "description": "Number of websites included by the hosting offering.", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + } + ], + "description": "Optional, read-only product metadata fields specific to hosting offerings. Additional merchant-defined properties are permitted.", + "title": "GoDaddy hosting product metadata" + }, + "info_code": { + "$id": "https://ucp.dev/schemas/shopping/types/info_code.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Info code identifying the type of informational message. Standard codes are defined in capability specs (see examples), and have standardized semantics; freeform codes are permitted.", + "examples": [ + "identity_optional", + "signal", + "free_shipping", + "not_found" + ], + "title": "Info Code", + "type": "string" + }, + "input_correlation": { + "$id": "https://ucp.dev/schemas/shopping/types/input_correlation.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Maps a request identifier to the variant it resolved to, with match semantics.", + "properties": { + "id": { + "description": "The identifier from the lookup request that resolved to this variant.", + "type": "string" + }, + "match": { + "description": "How the request identifier resolved to this variant. Well-known values: `exact` (input directly identifies this variant, e.g., variant ID, SKU), `featured` (server selected this variant as representative, e.g., product ID resolved to best match). Businesses MAY implement and provide additional resolution strategies.", + "examples": [ + "exact", + "featured" + ], + "type": "string" + } + }, + "title": "Input Correlation", + "type": "object" + }, + "item": { + "$id": "https://ucp.dev/schemas/shopping/types/item.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "category": { + "description": "GoDaddy extension routing category. Domain-registration items use domain.", + "type": "string" + }, + "id": { + "description": "The product identifier, often the SKU, required to resolve the product details associated with this line item. Should be recognized by both the Platform, and the Business.", + "type": "string" + }, + "image_url": { + "description": "Product image URI.", + "type": "string", + "ucp_request": "omit" + }, + "price": { + "$ref": "#/components/schemas/amount" + }, + "title": { + "description": "Product title.", + "type": "string", + "ucp_request": "omit" + } + }, + "title": "Item", + "type": "object" + }, + "line_item": { + "$id": "https://ucp.dev/schemas/shopping/types/line_item.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Line item object. Expected to use the currency of the parent object.", + "properties": { + "id": { + "type": "string", + "ucp_request": { + "create": "omit", + "update": "optional" + } + }, + "included_products": { + "description": "GoDaddy extension. Read-only resolved catalog-offer composition.", + "items": { + "$ref": "#/components/schemas/catalog-offer-included-product_schema" + }, + "type": "array", + "ucp_request": "omit" + }, + "input": { + "$ref": "#/components/schemas/com_godaddy_shopping_input_schema_input_value" + }, + "item": { + "$ref": "#/components/schemas/item" + }, + "parent_id": { + "description": "Parent line item identifier for any nested structures.", + "type": "string", + "ucp_request": { + "create": "omit", + "update": "optional" + } + }, + "quantity": { + "description": "Quantity of the item being purchased.", + "minimum": 1, + "type": "integer" + }, + "totals": { + "description": "Line item totals breakdown.", + "items": { + "$ref": "#/components/schemas/total" + }, + "type": "array", + "ucp_request": "omit" + } + }, + "title": "Line Item", + "type": "object" + }, + "link": { + "$id": "https://ucp.dev/schemas/shopping/types/link.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "title": { + "description": "Optional display text for the link. When provided, use this instead of generating from type.", + "type": "string" + }, + "type": { + "description": "Type of link. Well-known values: `privacy_policy`, `terms_of_service`, `refund_policy`, `shipping_policy`, `faq`. Consumers SHOULD handle unknown values gracefully by displaying them using the `title` field or omitting the link.", + "type": "string" + }, + "url": { + "description": "The actual URL pointing to the content to be displayed.", + "type": "string" + } + }, + "title": "Link", + "type": "object" + }, + "lookup_request": { + "$ref": "#/components/schemas/catalog_lookup_lookup_request" + }, + "lookup_response": { + "$ref": "#/components/schemas/catalog_lookup_lookup_response" + }, + "media": { + "$id": "https://ucp.dev/schemas/shopping/types/media.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Product media item (image, video, etc.).", + "properties": { + "alt_text": { + "description": "Accessibility text describing the media.", + "type": "string" + }, + "height": { + "description": "Height in pixels (for images/video).", + "minimum": 1, + "type": "integer" + }, + "type": { + "description": "Media type. Well-known values: `image`, `video`, `model_3d`.", + "type": "string" + }, + "url": { + "description": "URL to the media resource.", + "type": "string" + }, + "width": { + "description": "Width in pixels (for images/video).", + "minimum": 1, + "type": "integer" + } + }, + "title": "Media", + "type": "object" + }, + "message": { + "$id": "https://ucp.dev/schemas/shopping/types/message.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Container for error, warning, or info messages.", + "oneOf": [ + { + "$ref": "#/components/schemas/message_error" + }, + { + "$ref": "#/components/schemas/message_warning" + }, + { + "$ref": "#/components/schemas/message_info" + } + ], + "title": "Message", + "type": "object" + }, + "message_error": { + "$id": "https://ucp.dev/schemas/shopping/types/message_error.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "code": { + "$ref": "#/components/schemas/error_code" + }, + "content": { + "description": "Human-readable message.", + "type": "string" + }, + "content_type": { + "default": "plain", + "description": "Content format, default = plain.", + "enum": [ + "plain", + "markdown" + ], + "type": "string" + }, + "path": { + "description": "RFC 9535 JSONPath to the component the message refers to (e.g., $.items[1]).", + "type": "string" + }, + "severity": { + "description": "Reflects the resource state and recommended action. 'recoverable': platform can resolve by modifying inputs and retrying via API. 'requires_buyer_input': merchant requires information their API doesn't support collecting programmatically (checkout incomplete). 'requires_buyer_review': buyer must authorize before order placement due to policy, regulatory, or entitlement rules. 'unrecoverable': no valid resource exists to act on, retry with new resource or inputs. Errors with 'requires_*' severity contribute to 'status: requires_escalation'.", + "enum": [ + "recoverable", + "requires_buyer_input", + "requires_buyer_review", + "unrecoverable" + ], + "type": "string" + }, + "type": { + "const": "error", + "description": "Message type discriminator.", + "type": "string" + } + }, + "title": "Message Error", + "type": "object" + }, + "message_info": { + "$id": "https://ucp.dev/schemas/shopping/types/message_info.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "code": { + "$ref": "#/components/schemas/info_code" + }, + "content": { + "description": "Human-readable message.", + "type": "string" + }, + "content_type": { + "default": "plain", + "description": "Content format, default = plain.", + "enum": [ + "plain", + "markdown" + ], + "type": "string" + }, + "path": { + "description": "RFC 9535 JSONPath to the component the message refers to.", + "type": "string" + }, + "type": { + "const": "info", + "description": "Message type discriminator.", + "type": "string" + } + }, + "title": "Message Info", + "type": "object" + }, + "message_warning": { + "$id": "https://ucp.dev/schemas/shopping/types/message_warning.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "code": { + "$ref": "#/components/schemas/warning_code" + }, + "content": { + "description": "Human-readable warning message that MUST be displayed.", + "type": "string" + }, + "content_type": { + "default": "plain", + "description": "Content format, default = plain.", + "enum": [ + "plain", + "markdown" + ], + "type": "string" + }, + "image_url": { + "description": "URL to a required visual element (e.g., warning symbol, energy class label).", + "type": "string" + }, + "path": { + "description": "JSONPath (RFC 9535) to related field (e.g., $.line_items[0]).", + "type": "string" + }, + "presentation": { + "default": "notice", + "description": "Rendering contract for this warning. 'notice' (default): platform MUST display, MAY dismiss. 'disclosure': platform MUST display in proximity to the path-referenced component, MUST NOT hide or auto-dismiss. See specification for full contract.", + "type": "string" + }, + "type": { + "const": "warning", + "description": "Message type discriminator.", + "type": "string" + }, + "url": { + "description": "Reference URL for more information (e.g., regulatory site, registry entry, policy page).", + "type": "string" + } + }, + "title": "Message Warning", + "type": "object" + }, + "option_value": { + "$id": "https://ucp.dev/schemas/shopping/types/option_value.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A selectable value for a product option.", + "properties": { + "id": { + "description": "Optional server-assigned identifier for this option value. When present in a selected_option, the server SHOULD use it for matching instead of label.", + "type": "string" + }, + "label": { + "description": "Display text for this option value (e.g., 'Small', 'Blue').", + "type": "string" + } + }, + "title": "Option Value", + "type": "object" + }, + "order": { + "$defs": { + "platform_schema": { + "description": "Platform's order capability configuration.", + "properties": { + "webhook_url": { + "description": "URL where merchant sends order lifecycle events (webhooks).", + "type": "string" + } + }, + "title": "Platform Order Schema", + "type": "object" + } + }, + "$id": "https://ucp.dev/schemas/shopping/order.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Order schema with line items, buyer-facing fulfillment expectations, and event logs.", + "name": "dev.ucp.shopping.order", + "properties": { + "adjustments": { + "description": "Post-order events (refunds, returns, credits, disputes, cancellations, etc.) that exist independently of fulfillment.", + "items": { + "$ref": "#/components/schemas/adjustment" + }, + "type": "array" + }, + "attribution": { + "$ref": "#/components/schemas/attribution" + }, + "checkout_id": { + "description": "Associated checkout ID for reconciliation.", + "type": "string" + }, + "currency": { + "description": "ISO 4217 currency code. MUST match the currency from the originating checkout session.", + "type": "string", + "ucp_request": "omit" + }, + "fulfillment": { + "description": "Fulfillment data: buyer expectations and what actually happened.", + "properties": { + "events": { + "description": "Append-only event log of actual shipments. Each event references line items by ID.", + "items": { + "$ref": "#/components/schemas/fulfillment_event" + }, + "type": "array" + }, + "expectations": { + "description": "Buyer-facing groups representing when/how items will be delivered. Can be split, merged, or adjusted post-order.", + "items": { + "$ref": "#/components/schemas/expectation" + }, + "type": "array" + } + }, + "type": "object" + }, + "id": { + "description": "Unique order identifier.", + "type": "string" + }, + "label": { + "description": "Human-readable label for identifying the order. MUST only be provided by the business.", + "type": "string" + }, + "line_items": { + "description": "Line items representing what was purchased — can change post-order via edits or exchanges.", + "items": { + "$ref": "#/components/schemas/order_line_item" + }, + "type": "array" + }, + "messages": { + "description": "Business outcome messages (errors, warnings, informational). Present when the business needs to communicate status or issues to the platform.", + "items": { + "$ref": "#/components/schemas/message" + }, + "type": "array" + }, + "permalink_url": { + "description": "Permalink to access the order on merchant site.", + "type": "string" + }, + "totals": { + "$ref": "#/components/schemas/totals" + }, + "ucp": { + "$ref": "#/components/schemas/ucp_response_order_schema" + } + }, + "title": "Order", + "type": "object" + }, + "order_confirmation": { + "$id": "https://ucp.dev/schemas/shopping/types/order_confirmation.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Order details available at the time of checkout completion.", + "properties": { + "id": { + "description": "Unique order identifier.", + "type": "string" + }, + "label": { + "description": "Human-readable label for identifying the order. MUST only be provided by the business.", + "type": "string" + }, + "permalink_url": { + "description": "Permalink to access the order on merchant site.", + "type": "string" + } + }, + "title": "Order Confirmation", + "type": "object" + }, + "order_line_item": { + "$id": "https://ucp.dev/schemas/shopping/types/order_line_item.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "id": { + "description": "Line item identifier.", + "type": "string" + }, + "item": { + "$ref": "#/components/schemas/item" + }, + "parent_id": { + "description": "Parent line item identifier for any nested structures.", + "type": "string" + }, + "quantity": { + "description": "Quantity tracking for the line item.", + "properties": { + "fulfilled": { + "description": "Quantity fulfilled so far.", + "minimum": 0, + "type": "integer" + }, + "original": { + "description": "Quantity from the original checkout.", + "minimum": 0, + "type": "integer" + }, + "total": { + "description": "Current total active quantity. May differ from original due to post-order modifications (e.g., returns or cancellations).", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "status": { + "description": "Order line item fulfillment status. Known values: processing, partial, fulfilled, removed.", + "examples": [ + "processing", + "partial", + "fulfilled", + "removed" + ], + "type": "string" + }, + "totals": { + "description": "Line item totals breakdown.", + "items": { + "$ref": "#/components/schemas/total" + }, + "type": "array" + } + }, + "title": "Order Line Item", + "type": "object" + }, + "pagination_request": { + "description": "Pagination parameters for requests.", + "properties": { + "cursor": { + "description": "Opaque cursor from previous response.", + "type": "string" + }, + "limit": { + "default": 10, + "description": "Requested page size. Implementations MAY clamp to a lower maximum.", + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "pagination_response": { + "description": "Pagination information in responses.", + "if": { + "properties": { + "has_next_page": { + "const": true + } + } + }, + "properties": { + "cursor": { + "description": "Cursor to fetch the next page of results. MUST be present when has_next_page is true.", + "type": "string" + }, + "has_next_page": { + "description": "Whether more results are available.", + "type": "boolean" + }, + "total_count": { + "description": "Total number of matching items, if available.", + "minimum": 0, + "type": "integer" + } + }, + "then": {}, + "type": "object" + }, + "payment": { + "$id": "https://ucp.dev/schemas/shopping/payment.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Payment configuration containing handlers.", + "properties": { + "instruments": { + "description": "The payment instruments available for this payment. Each instrument is associated with a specific handler via the handler_id field. Handlers can extend the base payment_instrument schema to add handler-specific fields.", + "items": { + "$ref": "#/components/schemas/payment_instrument_selected_payment_instrument" + }, + "type": "array" + } + }, + "title": "Payment", + "type": "object" + }, + "payment_credential": { + "$id": "https://ucp.dev/schemas/shopping/types/payment_credential.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "description": "The base definition for any payment credential. Handlers define specific credential types.", + "properties": { + "type": { + "description": "The credential type discriminator. Specific schemas will constrain this to a constant value.", + "type": "string" + } + }, + "title": "Payment Credential", + "type": "object" + }, + "payment_handler_base": { + "allOf": [ + { + "$ref": "#/components/schemas/ucp_entity" + }, + { + "type": "object" + }, + { + "properties": { + "available_instruments": { + "description": "Instrument types this handler supports, with optional constraints. When absent, every instrument should be considered available.", + "items": { + "$ref": "#/components/schemas/available_payment_instrument" + }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + } + ] + }, + "payment_handler_business_schema": { + "allOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/ucp_entity" + }, + { + "type": "object" + }, + { + "properties": { + "available_instruments": { + "description": "Instrument types this handler supports, with optional constraints. When absent, every instrument should be considered available.", + "items": { + "$ref": "#/components/schemas/available_payment_instrument" + }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + } + ] + } + ], + "description": "Business declaration for discovery profiles. May include partial config state required for discovery.", + "title": "Payment Handler (Business Schema)" + }, + "payment_handler_platform_schema": { + "allOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/ucp_entity" + }, + { + "type": "object" + }, + { + "properties": { + "available_instruments": { + "description": "Instrument types this handler supports, with optional constraints. When absent, every instrument should be considered available.", + "items": { + "$ref": "#/components/schemas/available_payment_instrument" + }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + } + ] + }, + {} + ], + "description": "Platform declaration for discovery profiles. May include partial config state required for discovery.", + "title": "Payment Handler (Platform Schema)" + }, + "payment_handler_response_schema": { + "allOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/ucp_entity" + }, + { + "type": "object" + }, + { + "properties": { + "available_instruments": { + "description": "Instrument types this handler supports, with optional constraints. When absent, every instrument should be considered available.", + "items": { + "$ref": "#/components/schemas/available_payment_instrument" + }, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + } + ] + } + ], + "description": "Handler reference in responses. May include full config state for runtime usage of the handler.", + "title": "Payment Handler (Response Schema)" + }, + "payment_instrument_selected_payment_instrument": { + "allOf": [ + {}, + { + "properties": { + "id": { + "type": "string" + }, + "selected": { + "description": "Whether this instrument is selected by the user.", + "type": "boolean" + } + }, + "type": "object" + } + ], + "description": "A payment instrument with selection state.", + "title": "Selected Payment Instrument" + }, + "platform_fulfillment_config": { + "$id": "https://ucp.dev/schemas/shopping/types/platform_fulfillment_config.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Platform's fulfillment configuration.", + "properties": { + "supports_multi_group": { + "default": false, + "description": "Enables multiple groups per method.", + "type": "boolean" + } + }, + "title": "Platform Fulfillment Config", + "type": "object" + }, + "postal_address": { + "$id": "https://ucp.dev/schemas/shopping/types/postal_address.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "address_country": { + "description": "The country. Recommended to be in 2-letter ISO 3166-1 alpha-2 format, for example \"US\". For backward compatibility, a 3-letter ISO 3166-1 alpha-3 country code such as \"SGP\" or a full country name such as \"Singapore\" can also be used.", + "type": "string" + }, + "address_locality": { + "description": "The locality in which the street address is, and which is in the region. For example, Mountain View.", + "type": "string" + }, + "address_region": { + "description": "The region in which the locality is, and which is in the country. Required for applicable countries (i.e. state in US, province in CA). For example, California or another appropriate first-level Administrative division.", + "type": "string" + }, + "extended_address": { + "description": "An address extension such as an apartment number, C/O or alternative name.", + "type": "string" + }, + "first_name": { + "description": "Optional. First name of the contact associated with the address.", + "type": "string" + }, + "last_name": { + "description": "Optional. Last name of the contact associated with the address.", + "type": "string" + }, + "phone_number": { + "description": "Optional. Phone number of the contact associated with the address.", + "type": "string" + }, + "postal_code": { + "description": "The postal code. For example, 94043.", + "type": "string" + }, + "street_address": { + "description": "The street address.", + "type": "string" + } + }, + "title": "Postal Address", + "type": "object" + }, + "price": { + "$id": "https://ucp.dev/schemas/shopping/types/price.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Price with explicit currency.", + "properties": { + "amount": { + "$ref": "#/components/schemas/amount" + }, + "currency": { + "description": "ISO 4217 currency code (e.g., 'USD', 'EUR', 'GBP').", + "type": "string" + } + }, + "title": "Price", + "type": "object" + }, + "price_filter": { + "$id": "https://ucp.dev/schemas/shopping/types/price_filter.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Price range filter denominated in context.currency. When context.currency matches the presentment currency, businesses apply the filter directly. When it differs, businesses SHOULD convert filter values to the presentment currency before applying; if conversion is not supported, businesses MAY ignore the filter and SHOULD indicate this via a message. When context.currency is absent, filter denomination is ambiguous and businesses MAY ignore it.", + "properties": { + "max": { + "$ref": "#/components/schemas/amount" + }, + "min": { + "$ref": "#/components/schemas/amount" + } + }, + "title": "Price Filter", + "type": "object" + }, + "price_range": { + "$id": "https://ucp.dev/schemas/shopping/types/price_range.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A price range representing minimum and maximum values (e.g., across product variants).", + "properties": { + "max": { + "$ref": "#/components/schemas/price" + }, + "min": { + "$ref": "#/components/schemas/price" + } + }, + "title": "Price Range", + "type": "object" + }, + "product": { + "$id": "https://ucp.dev/schemas/shopping/types/product.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A product in the catalog with variants and options.", + "properties": { + "action_required": { + "$ref": "#/components/schemas/com_godaddy_shopping_catalog_action_schema_action_required" + }, + "categories": { + "description": "Product categories with optional taxonomy identifiers.", + "items": { + "$ref": "#/components/schemas/category" + }, + "type": "array" + }, + "description": { + "$ref": "#/components/schemas/description" + }, + "handle": { + "description": "URL-safe slug for SEO-friendly URLs (e.g., 'blue-runner-pro'). Use id for stable API references.", + "type": "string" + }, + "id": { + "description": "Global ID (GID) uniquely identifying this product.", + "type": "string" + }, + "list_price_range": { + "$ref": "#/components/schemas/price_range" + }, + "media": { + "description": "Product media (images, videos, 3D models). First item is the featured media for listings.", + "items": { + "$ref": "#/components/schemas/media" + }, + "type": "array" + }, + "metadata": { + "allOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/product-metadata_schema" + } + ], + "description": "Business-defined custom data extending the standard Product model. GoDaddy documents known optional metadata through its compatible structured schema." + }, + "options": { + "description": "Product options (Size, Color, etc.).", + "items": { + "$ref": "#/components/schemas/product_option" + }, + "type": "array" + }, + "price_range": { + "$ref": "#/components/schemas/price_range" + }, + "rating": { + "$ref": "#/components/schemas/rating" + }, + "tags": { + "description": "Product tags for categorization and search.", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "description": "Product title.", + "type": "string" + }, + "url": { + "description": "Canonical product page URL.", + "type": "string" + }, + "variants": { + "description": "Purchasable variants of this product. First item is the featured variant for listings.", + "items": { + "$ref": "#/components/schemas/variant" + }, + "minItems": 1, + "type": "array" + } + }, + "title": "Product", + "type": "object" + }, + "product-metadata_schema": { + "$id": "https://godaddy.com/ucp/schemas/product-metadata.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "allOf": [ + { + "$ref": "#/components/schemas/email_schema" + }, + { + "$ref": "#/components/schemas/hosting_schema" + }, + { + "$ref": "#/components/schemas/ssl_schema" + } + ], + "description": "Optional public GoDaddy product metadata not represented by native UCP Product fields. It composes shared metadata with the documented email, hosting, and SSL metadata field sets without requiring a family discriminator or asserting family-exclusive validation. All properties are optional and additional merchant-defined properties are permitted.", + "title": "GoDaddy product metadata", + "type": "object" + }, + "product-plan_schema": { + "$id": "https://godaddy.com/ucp/schemas/product-plan.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Customer-visible GoDaddy plan, tier, or SKU-aligned plan code returned in product metadata. Informational and read-only; it does not select, configure, or price a product.", + "minLength": 1, + "title": "GoDaddy product plan", + "type": "string" + }, + "product_option": { + "$id": "https://ucp.dev/schemas/shopping/types/product_option.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A product option such as size, color, or material.", + "properties": { + "name": { + "description": "Option name (e.g., 'Size', 'Color').", + "type": "string" + }, + "values": { + "description": "Available values for this option.", + "items": { + "$ref": "#/components/schemas/option_value" + }, + "minItems": 1, + "type": "array" + } + }, + "title": "Product Option", + "type": "object" + }, + "rating": { + "$id": "https://ucp.dev/schemas/shopping/types/rating.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Product rating aggregate.", + "properties": { + "count": { + "description": "Number of reviews contributing to the rating.", + "minimum": 0, + "type": "integer" + }, + "scale_max": { + "description": "Maximum value on the rating scale (e.g., 5 for 5-star).", + "minimum": 1, + "type": "number" + }, + "scale_min": { + "default": 1, + "description": "Minimum value on the rating scale (e.g., 1 for 1-5 stars).", + "minimum": 0, + "type": "number" + }, + "value": { + "description": "Average rating value.", + "minimum": 0, + "type": "number" + } + }, + "title": "Rating", + "type": "object" + }, + "retail_location": { + "$id": "https://ucp.dev/schemas/shopping/types/retail_location.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "description": "A pickup location (retail store, locker, etc.).", + "properties": { + "address": { + "$ref": "#/components/schemas/postal_address" + }, + "id": { + "description": "Unique location identifier.", + "type": "string", + "ucp_request": "omit" + }, + "name": { + "description": "Location name (e.g., store name).", + "type": "string" + } + }, + "title": "Retail Location", + "type": "object", + "ucp_shared_request": true + }, + "reverse_domain_name": { + "$id": "https://ucp.dev/schemas/shopping/types/reverse_domain_name.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Reverse-domain identifier used for collision-safe namespacing of capabilities, services, handlers, eligibility claims, and extension-contributed keys. Must contain at least two dot-separated segments (e.g., 'dev.ucp.shopping.checkout', 'com.example.loyalty_gold').", + "title": "Reverse Domain Name", + "type": "string" + }, + "schema": {}, + "search_filters": { + "$id": "https://ucp.dev/schemas/shopping/types/search_filters.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "description": "Filter criteria to narrow search results. All specified filters combine with AND logic.", + "properties": { + "categories": { + "description": "Filter by product categories (OR logic — matches products in any listed categories). Values match against the value field in product category entries. Valid values can be discovered from the categories field in search results, merchant documentation, or standard taxonomies that businesses may align with.", + "items": { + "type": "string" + }, + "type": "array" + }, + "price": { + "$ref": "#/components/schemas/price_filter" + } + }, + "title": "Search Filters", + "type": "object" + }, + "search_request": { + "$ref": "#/components/schemas/catalog_search_search_request" + }, + "search_response": { + "$ref": "#/components/schemas/catalog_search_search_response" + }, + "selected_option": { + "$id": "https://ucp.dev/schemas/shopping/types/selected_option.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A specific option selection on a variant (e.g., Size: Large).", + "properties": { + "id": { + "description": "Optional option value identifier from option_value.id. When present, the server SHOULD use it for matching; name and label remain required for display.", + "type": "string" + }, + "label": { + "description": "Selected option label (e.g., 'Large').", + "type": "string" + }, + "name": { + "description": "Option name (e.g., 'Size').", + "type": "string" + } + }, + "title": "Selected Option", + "type": "object" + }, + "service_base": { + "allOf": [ + { + "$ref": "#/components/schemas/ucp_entity" + }, + { + "properties": { + "endpoint": { + "description": "Endpoint URL for this transport binding.", + "type": "string" + }, + "transport": { + "description": "Transport protocol for this service binding.", + "enum": [ + "rest", + "mcp", + "a2a", + "embedded" + ], + "type": "string" + } + }, + "type": "object" + } + ] + }, + "service_business_schema": { + "allOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/ucp_entity" + }, + { + "properties": { + "endpoint": { + "description": "Endpoint URL for this transport binding.", + "type": "string" + }, + "transport": { + "description": "Transport protocol for this service binding.", + "enum": [ + "rest", + "mcp", + "a2a", + "embedded" + ], + "type": "string" + } + }, + "type": "object" + } + ] + }, + { + "anyOf": [ + { + "properties": { + "transport": { + "const": "rest" + } + } + }, + { + "properties": { + "transport": { + "const": "mcp" + } + } + }, + { + "properties": { + "transport": { + "const": "a2a" + } + } + }, + { + "properties": { + "config": { + "$ref": "#/components/schemas/embedded_config" + }, + "transport": { + "const": "embedded" + } + } + } + ] + } + ], + "description": "Service binding for business/merchant configuration. May override platform endpoints.", + "title": "Service (Business Schema)" + }, + "service_platform_schema": { + "allOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/ucp_entity" + }, + { + "properties": { + "endpoint": { + "description": "Endpoint URL for this transport binding.", + "type": "string" + }, + "transport": { + "description": "Transport protocol for this service binding.", + "enum": [ + "rest", + "mcp", + "a2a", + "embedded" + ], + "type": "string" + } + }, + "type": "object" + } + ] + }, + {}, + { + "anyOf": [ + { + "properties": { + "transport": { + "const": "rest" + } + } + }, + { + "properties": { + "transport": { + "const": "mcp" + } + } + }, + { + "properties": { + "transport": { + "const": "a2a" + } + } + }, + { + "properties": { + "transport": { + "const": "embedded" + } + } + } + ] + } + ], + "description": "Full service declaration for platform-level discovery. All transports require `version`, `spec`, and `transport`. REST, MCP, and embedded additionally require `schema`.", + "title": "Service (Platform Schema)" + }, + "service_response_schema": { + "allOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/ucp_entity" + }, + { + "properties": { + "endpoint": { + "description": "Endpoint URL for this transport binding.", + "type": "string" + }, + "transport": { + "description": "Transport protocol for this service binding.", + "enum": [ + "rest", + "mcp", + "a2a", + "embedded" + ], + "type": "string" + } + }, + "type": "object" + } + ] + }, + { + "anyOf": [ + { + "properties": { + "transport": { + "const": "rest" + } + } + }, + { + "properties": { + "transport": { + "const": "mcp" + } + } + }, + { + "properties": { + "transport": { + "const": "a2a" + } + } + }, + { + "properties": { + "config": { + "$ref": "#/components/schemas/embedded_config" + }, + "transport": { + "const": "embedded" + } + } + } + ] + } + ], + "description": "Service binding in API responses. Includes per-resource transport configuration via typed config.", + "title": "Service (Response Schema)" + }, + "shipping_destination": { + "$id": "https://ucp.dev/schemas/shopping/types/shipping_destination.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "$ref": "#/components/schemas/postal_address" + }, + { + "properties": { + "id": { + "description": "ID specific to this shipping destination.", + "type": "string", + "ucp_request": "optional" + } + }, + "type": "object" + } + ], + "description": "Shipping destination.", + "title": "Shipping Destination", + "type": "object", + "ucp_shared_request": true + }, + "signals": { + "$id": "https://ucp.dev/schemas/shopping/types/signals.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "description": "Environment data provided by the platform to support authorization and abuse prevention. Values MUST NOT be buyer-asserted claims — platforms provide signals based on direct observation or independently verifiable third-party attestations. All signal keys MUST use reverse-domain naming to ensure provenance and prevent collisions when multiple extensions contribute to the shared namespace.", + "properties": { + "dev.ucp.buyer_ip": { + "description": "Client's IP address (IPv4 or IPv6).", + "type": "string" + }, + "dev.ucp.user_agent": { + "description": "Client's HTTP User-Agent header or equivalent.", + "type": "string" + } + }, + "propertyNames": { + "description": "Reverse-domain identifier (e.g., dev.ucp.buyer_ip, com.example.device_id)." + }, + "title": "Signals", + "type": "object" + }, + "signed_amount": { + "$id": "https://ucp.dev/schemas/shopping/types/signed_amount.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Monetary amount in the currency's minor unit as defined by ISO 4217. Refer to the currency's exponent to determine minor-to-major ratio (e.g., 2 for USD, 0 for JPY, 3 for KWD). May be negative — the sign is intrinsic to the value (e.g., discounts are negative, charges are positive).", + "title": "Signed Amount", + "type": "integer" + }, + "ssl_schema": { + "$id": "https://godaddy.com/ucp/schemas/product-metadata/ssl.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "$ref": "#/components/schemas/base_schema" + }, + { + "additionalProperties": true, + "properties": { + "assurance": { + "description": "Certificate assurance level, when applicable.", + "type": "string" + }, + "managed": { + "description": "Whether a certificate is managed, when applicable.", + "type": "boolean" + }, + "number_of_subject_alt_names": { + "description": "Number of certificate subject alternative names, when applicable.", + "minimum": 0, + "type": "integer" + }, + "wildcard": { + "description": "Whether a certificate covers wildcard hostnames, when applicable.", + "type": "boolean" + } + }, + "type": "object" + } + ], + "description": "Optional, read-only product metadata fields specific to SSL certificate offerings. Additional merchant-defined properties are permitted.", + "title": "GoDaddy SSL product metadata" + }, + "total": { + "$id": "https://ucp.dev/schemas/shopping/types/total.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "discount", + "items_discount" + ] + } + } + }, + "then": { + "properties": { + "amount": { + "exclusiveMaximum": 0 + } + } + } + }, + { + "if": { + "properties": { + "type": { + "enum": [ + "subtotal", + "fulfillment", + "tax", + "fee" + ] + } + } + }, + "then": { + "properties": { + "amount": { + "minimum": 0 + } + } + } + } + ], + "description": "A cost breakdown entry with a category, amount, and optional display text.", + "properties": { + "amount": { + "$ref": "#/components/schemas/signed_amount" + }, + "display_text": { + "description": "Text to display against the amount. Should reflect appropriate method (e.g., 'Shipping', 'Delivery').", + "type": "string", + "ucp_request": "omit" + }, + "type": { + "description": "Cost category. Well-known values: subtotal, items_discount, discount, fulfillment, tax, fee, total. Businesses MAY use additional values.", + "type": "string", + "ucp_request": "omit" + } + }, + "title": "Total", + "type": "object" + }, + "totals": { + "$id": "https://ucp.dev/schemas/shopping/types/totals.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "contains": { + "properties": { + "type": { + "const": "subtotal" + } + } + }, + "maxContains": 1, + "minContains": 1 + }, + { + "contains": { + "properties": { + "type": { + "const": "total" + } + } + }, + "maxContains": 1, + "minContains": 1 + } + ], + "description": "Pricing breakdown provided by the business. MUST contain exactly one subtotal and one total entry. Detail types (tax, fee, discount, fulfillment) may appear multiple times for itemization. Platforms MUST render all entries in order using display_text and amount.", + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/total" + }, + { + "properties": { + "lines": { + "description": "Optional itemized breakdown. The parent entry is always rendered; lines are supplementary. Sum of line amounts MUST equal the parent entry amount.", + "items": { + "description": "Sub-line entry. Additional metadata MAY be included.", + "properties": { + "amount": { + "$ref": "#/components/schemas/signed_amount" + }, + "display_text": { + "description": "Human-readable label for this sub-line.", + "type": "string" + } + }, + "type": "object" + }, + "type": "array", + "ucp_request": "omit" + } + }, + "type": "object" + }, + { + "if": { + "properties": { + "type": { + "not": { + "enum": [ + "subtotal", + "items_discount", + "discount", + "fulfillment", + "tax", + "fee", + "total" + ] + } + } + } + }, + "then": {} + } + ] + }, + "title": "Totals", + "type": "array" + }, + "ucp-refs_schema_attribution": { + "$ref": "#/components/schemas/attribution" + }, + "ucp-refs_schema_buyer": { + "$ref": "#/components/schemas/buyer" + }, + "ucp-refs_schema_checkout": { + "$ref": "#/components/schemas/checkout" + }, + "ucp-refs_schema_context": { + "$ref": "#/components/schemas/context" + }, + "ucp-refs_schema_fulfillment": { + "$ref": "#/components/schemas/fulfillment" + }, + "ucp-refs_schema_get_product_request": { + "$ref": "#/components/schemas/catalog_lookup_get_product_request" + }, + "ucp-refs_schema_get_product_response": { + "$ref": "#/components/schemas/catalog_lookup_get_product_response" + }, + "ucp-refs_schema_line_item": { + "$ref": "#/components/schemas/line_item" + }, + "ucp-refs_schema_lookup_request": { + "$ref": "#/components/schemas/catalog_lookup_lookup_request" + }, + "ucp-refs_schema_lookup_response": { + "$ref": "#/components/schemas/catalog_lookup_lookup_response" + }, + "ucp-refs_schema_payment": { + "$ref": "#/components/schemas/payment" + }, + "ucp-refs_schema_price": { + "$ref": "#/components/schemas/price" + }, + "ucp-refs_schema_search_request": { + "$ref": "#/components/schemas/catalog_search_search_request" + }, + "ucp-refs_schema_search_response": { + "$ref": "#/components/schemas/catalog_search_search_response" + }, + "ucp-refs_schema_signals": { + "$ref": "#/components/schemas/signals" + }, + "ucp-refs_schema_variant": { + "$ref": "#/components/schemas/variant" + }, + "ucp_entity": { + "description": "Shared foundation for all UCP entities.", + "properties": { + "config": { + "additionalProperties": true, + "description": "Entity-specific configuration. Structure defined by each entity's schema.", + "type": "object" + }, + "id": { + "description": "Unique identifier for this entity instance. Used to disambiguate when multiple instances exist.", + "type": "string" + }, + "schema": { + "description": "URL to JSON Schema defining this entity's structure and payloads.", + "type": "string" + }, + "spec": { + "description": "URL to human-readable specification document.", + "type": "string" + }, + "version": { + "description": "UCP version in YYYY-MM-DD format.", + "type": "string" + } + }, + "type": "object" + }, + "ucp_response_catalog_schema": { + "allOf": [ + { + "description": "Base UCP metadata with shared properties for all schema types.", + "properties": { + "capabilities": {}, + "payment_handlers": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/payment_handler_base" + }, + "type": "array" + }, + "description": "Payment handler registry keyed by reverse-domain name.", + "propertyNames": { + "$ref": "#/components/schemas/reverse_domain_name" + }, + "type": "object" + }, + "services": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/service_base" + }, + "type": "array" + }, + "description": "Service registry keyed by reverse-domain name.", + "propertyNames": { + "$ref": "#/components/schemas/reverse_domain_name" + }, + "type": "object" + }, + "status": { + "default": "success", + "description": "Application-level status of the UCP operation.", + "enum": [ + "success", + "error" + ], + "type": "string" + }, + "version": { + "description": "UCP version in YYYY-MM-DD format.", + "type": "string" + } + }, + "type": "object" + }, + { + "properties": { + "capabilities": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/capability_response_schema" + } + } + } + } + } + ], + "description": "UCP metadata for catalog responses.", + "title": "UCP Catalog Response Schema" + }, + "ucp_response_checkout_schema": { + "allOf": [ + { + "description": "Base UCP metadata with shared properties for all schema types.", + "properties": { + "capabilities": {}, + "payment_handlers": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/payment_handler_base" + }, + "type": "array" + }, + "description": "Payment handler registry keyed by reverse-domain name.", + "propertyNames": { + "$ref": "#/components/schemas/reverse_domain_name" + }, + "type": "object" + }, + "services": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/service_base" + }, + "type": "array" + }, + "description": "Service registry keyed by reverse-domain name.", + "propertyNames": { + "$ref": "#/components/schemas/reverse_domain_name" + }, + "type": "object" + }, + "status": { + "default": "success", + "description": "Application-level status of the UCP operation.", + "enum": [ + "success", + "error" + ], + "type": "string" + }, + "version": { + "description": "UCP version in YYYY-MM-DD format.", + "type": "string" + } + }, + "type": "object" + }, + { + "properties": { + "capabilities": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/capability_response_schema" + } + } + }, + "payment_handlers": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/payment_handler_response_schema" + } + } + }, + "services": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/service_response_schema" + } + } + } + } + } + ], + "description": "UCP metadata for checkout responses.", + "title": "UCP Checkout Response Schema" + }, + "ucp_response_order_schema": { + "allOf": [ + { + "description": "Base UCP metadata with shared properties for all schema types.", + "properties": { + "capabilities": {}, + "payment_handlers": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/payment_handler_base" + }, + "type": "array" + }, + "description": "Payment handler registry keyed by reverse-domain name.", + "propertyNames": { + "$ref": "#/components/schemas/reverse_domain_name" + }, + "type": "object" + }, + "services": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/service_base" + }, + "type": "array" + }, + "description": "Service registry keyed by reverse-domain name.", + "propertyNames": { + "$ref": "#/components/schemas/reverse_domain_name" + }, + "type": "object" + }, + "status": { + "default": "success", + "description": "Application-level status of the UCP operation.", + "enum": [ + "success", + "error" + ], + "type": "string" + }, + "version": { + "description": "UCP version in YYYY-MM-DD format.", + "type": "string" + } + }, + "type": "object" + }, + { + "properties": { + "capabilities": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/capability_response_schema" + } + } + } + } + } + ], + "description": "UCP metadata for order responses. No payment handlers needed post-purchase.", + "title": "UCP Order Response Schema" + }, + "ucp_success": { + "allOf": [ + { + "description": "Base UCP metadata with shared properties for all schema types.", + "properties": { + "capabilities": {}, + "payment_handlers": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/payment_handler_base" + }, + "type": "array" + }, + "description": "Payment handler registry keyed by reverse-domain name.", + "propertyNames": { + "$ref": "#/components/schemas/reverse_domain_name" + }, + "type": "object" + }, + "services": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/service_base" + }, + "type": "array" + }, + "description": "Service registry keyed by reverse-domain name.", + "propertyNames": { + "$ref": "#/components/schemas/reverse_domain_name" + }, + "type": "object" + }, + "status": { + "default": "success", + "description": "Application-level status of the UCP operation.", + "enum": [ + "success", + "error" + ], + "type": "string" + }, + "version": { + "description": "UCP version in YYYY-MM-DD format.", + "type": "string" + } + }, + "type": "object" + }, + { + "properties": { + "status": { + "const": "success" + } + } + } + ], + "description": "UCP metadata with status 'success'. Use for response branches that carry the expected payload." + }, + "variant": { + "$id": "https://ucp.dev/schemas/shopping/types/variant.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A purchasable variant of a product with specific option selections.", + "properties": { + "availability": { + "description": "Variant availability for purchase.", + "properties": { + "available": { + "description": "Whether this variant can be purchased. See status for fulfillment details.", + "type": "boolean" + }, + "status": { + "description": "Qualifies available with fulfillment state. Well-known values: `in_stock`, `backorder`, `preorder`, `out_of_stock`, `discontinued`.", + "type": "string" + } + }, + "type": "object" + }, + "barcodes": { + "description": "Industry-standard product identifiers for cross-reference and correlation.", + "items": { + "properties": { + "type": { + "description": "Barcode standard. Well-known values: UPC, EAN, ISBN, GTIN, JAN.", + "type": "string" + }, + "value": { + "description": "Barcode value.", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "categories": { + "description": "Variant categories with optional taxonomy identifiers.", + "items": { + "$ref": "#/components/schemas/category" + }, + "type": "array" + }, + "description": { + "$ref": "#/components/schemas/description" + }, + "handle": { + "description": "URL-safe variant handle/slug.", + "type": "string" + }, + "id": { + "description": "Global ID (GID) uniquely identifying this variant. Used as item.id in checkout.", + "type": "string" + }, + "included_products": { + "description": "GoDaddy extension. Read-only customer-visible catalog-offer composition.", + "items": { + "$ref": "#/components/schemas/catalog-offer-included-product_schema" + }, + "type": "array", + "ucp_request": "omit" + }, + "input_schema": { + "$ref": "#/components/schemas/com_godaddy_shopping_input_schema_input_schema" + }, + "list_price": { + "$ref": "#/components/schemas/price" + }, + "media": { + "description": "Variant media (images, videos, 3D models). First item is the featured media for listings.", + "items": { + "$ref": "#/components/schemas/media" + }, + "type": "array" + }, + "metadata": { + "allOf": [ + { + "type": "object" + }, + { + "$ref": "#/components/schemas/variant-metadata_schema" + } + ], + "description": "Business-defined custom data extending the standard Variant model. GoDaddy documents known optional metadata through its compatible structured schema." + }, + "options": { + "description": "Option values that define this variant (e.g., Color: Blue, Size: Large).", + "items": { + "$ref": "#/components/schemas/selected_option" + }, + "type": "array" + }, + "plan": { + "$ref": "#/components/schemas/product-plan_schema" + }, + "price": { + "$ref": "#/components/schemas/price" + }, + "rating": { + "$ref": "#/components/schemas/rating" + }, + "renewal_price": { + "$ref": "#/components/schemas/price" + }, + "seller": { + "description": "Optional seller context for this variant.", + "properties": { + "links": { + "description": "Seller policy and information links.", + "items": { + "$ref": "#/components/schemas/link" + }, + "type": "array" + }, + "name": { + "description": "Seller display name.", + "type": "string" + } + }, + "type": "object" + }, + "sku": { + "description": "Business-assigned identifier for inventory and fulfillment.", + "type": "string" + }, + "tags": { + "description": "Variant tags for categorization and search.", + "items": { + "type": "string" + }, + "type": "array" + }, + "tier": { + "description": "GoDaddy extension. Named tier for this Variant.", + "type": "string", + "ucp_request": "omit" + }, + "title": { + "description": "Variant display title (e.g., 'Blue / Large').", + "type": "string" + }, + "unit_price": { + "description": "Price per standard unit of measurement. MAY be omitted when unit pricing does not apply.", + "properties": { + "amount": { + "$ref": "#/components/schemas/amount" + }, + "currency": { + "description": "ISO 4217 currency code.", + "type": "string" + }, + "measure": { + "description": "Product quantity in packaging (e.g., 750ml bottle).", + "properties": { + "unit": { + "description": "Unit of measurement.", + "type": "string" + }, + "value": { + "description": "Package quantity.", + "type": "number" + } + }, + "type": "object" + }, + "reference": { + "description": "Denominator for unit price display (e.g., per 100ml, per 1kg).", + "properties": { + "unit": { + "description": "Unit of measurement.", + "type": "string" + }, + "value": { + "description": "Reference quantity.", + "type": "integer" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "url": { + "description": "Canonical variant page URL.", + "type": "string" + } + }, + "title": "Variant", + "type": "object" + }, + "variant-metadata_schema": { + "$id": "https://godaddy.com/ucp/schemas/variant-metadata.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + {}, + {} + ], + "description": "Optional public GoDaddy variant metadata not represented by native UCP Variant fields or explicit GoDaddy extension fields. It composes shared metadata with documented hosting Variant fields without requiring a product-family discriminator or asserting family-exclusive validation. All properties use canonical snake_case GoDaddy wire names and additional merchant-defined properties are permitted.", + "title": "GoDaddy variant metadata" + }, + "warning_code": { + "$id": "https://ucp.dev/schemas/shopping/types/warning_code.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "Warning code identifying the type of warning. Standard codes are defined in capability specs (see examples), and have standardized semantics; freeform codes are permitted.", + "examples": [ + "final_sale", + "prop65", + "fulfillment_changed", + "age_restricted" + ], + "title": "Warning Code", + "type": "string" + } + }, + "securitySchemes": { + "oauth2": { + "description": "Platform self-authenticating (client-credentials) or acting on\nbehalf of a buyer (authorization-code), presented via the\n`Authorization: Bearer ` header. Corresponds to the\n`authorization` header parameter.\n", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://api.godaddy.com/v2/oauth2/authorize", + "scopes": { + "shopping.catalog:read": "Search, browse, and retrieve catalog products, variants, availability, pricing, and related product details.", + "shopping.checkout:execute": "Create, retrieve, update, cancel, and complete checkout sessions, including submitting a purchase.", + "shopping.order:read": "Retrieve order details and status, including line items, pricing, fulfillment, and payment-related state, without modifying the order." + }, + "tokenUrl": "https://api.godaddy.com/v2/oauth2/token" + }, + "clientCredentials": { + "scopes": { + "shopping.catalog:read": "Search, browse, and retrieve catalog products, variants, availability, pricing, and related product details.", + "shopping.checkout:execute": "Create, retrieve, update, cancel, and complete checkout sessions, including submitting a purchase.", + "shopping.order:read": "Retrieve order details and status, including line items, pricing, fulfillment, and payment-related state, without modifying the order." + }, + "tokenUrl": "https://api.godaddy.com/v2/oauth2/token" + } + }, + "type": "oauth2" + } + } + }, + "externalDocs": { + "description": "Universal Commerce Protocol specification (v2026-04-08)", + "url": "https://ucp.dev/2026-04-08/specification/overview" + }, + "info": { + "description": "GoDaddy Shopping REST API contract for Universal Commerce Protocol (UCP).\nThis document is GoDaddy API product version 1.0.0; the UCP protocol version\nis pinned separately in `ucp.version` on every response and in the\n`requires.protocol` constraint of every extension schema.\n\nThe UCP 2026-04-08 schema dependency closure is vendored under\n`schemas/ucp/`; each copied document retains its upstream canonical `$id`.\nCompatible GoDaddy adaptations are made only in those vendored UCP documents\nor in negotiated GoDaddy extension schemas; this OpenAPI document directly\nreferences the vendored UCP contracts and retains only transport wiring and\nendpoint-exclusive response composition.\n\nGoDaddy-owned schemas use repository-relative `$ref` values. Their published identifiers use the\n`https://godaddy.com/ucp/...` namespace (apex authority required by UCP\nreverse-domain binding). GoDaddy extensions are available only after\ncapability negotiation; the GoDaddy UCP business profile (including the\n`/.well-known/ucp` discovery endpoint) is maintained in\n`gdcorp-platform/ucp-provider-specification`.\n\n**Registered platform-standard deviations (forced by UCP protocol):**\n\n1. **Error model** — UCP models business-logic failure as `200` with a `oneOf`\n between the success shape and `error_response` (`{messages: [...]}`). This\n deviates from GoDaddy's `error.json` must-have and conventional `4xx` codes\n for business errors. Deviation is forced by the protocol for every negotiated\n operation; the platform `error.json` pattern cannot apply to a UCP response\n envelope. Registered deviation.\n\n2. **Transport errors** — `401`, `404`, and `429` responses also use UCP's\n `error_response` shape for consistency, which is a second deviation from the\n platform standard. This is a defensible consistency choice within the same\n protocol adapter. Registered deviation.\n\n3. **`Request-Id` required** — Platform standard sets `required: false` on\n correlation headers (gateway enforces). UCP mandates `Request-Id` as\n `required: true` on all service operations. Deviation is forced by the\n protocol. Registered deviation.\n\n4. **Pagination** — UCP-native `search_catalog` specifies cursor-based pagination\n (`cursor` / `limit` / `has_next_page`) in place of GoDaddy platform-standard\n offset pagination (`pageSize` / `page`) and HATEOAS `links` response fields.\n Deviation is forced by the UCP protocol shape for `search_catalog`. Registered deviation.\n\n5. **`action_required` container** — UCP `2026-04-08` has no native product\n `actions` map. GoDaddy ships a bespoke `com.godaddy.shopping.catalog_action`\n extension carrying `product.action_required` as a temporary vendor bridge.\n Migration to the upstream native `actions` map will be evaluated at the next\n approved UCP-baseline upgrade; see `USER-GUIDE.md`. Registered deviation.\n\n6. **REST binding** — UCP's canonical REST binding uses the Catalog POST paths,\n `PUT /checkout-sessions/{id}` for `update_checkout`, and bare `id` path\n parameters for Checkout and Order resources. These depart from GoDaddy\n platform URI, method, and parameter naming conventions. Registered deviation.\n", + "title": "UCP Commerce API (GoDaddy)", + "version": "1.0.0", + "x-lifecycle": "Beta", + "x-ucp-schema-urls": { + "catalog_lookup": "https://ucp.dev/2026-04-08/schemas/shopping/catalog_lookup.json", + "catalog_search": "https://ucp.dev/2026-04-08/schemas/shopping/catalog_search.json", + "checkout": "https://ucp.dev/2026-04-08/schemas/shopping/checkout.json", + "fulfillment": "https://ucp.dev/2026-04-08/schemas/shopping/fulfillment.json", + "order": "https://ucp.dev/2026-04-08/schemas/shopping/order.json", + "rest_openapi": "https://ucp.dev/2026-04-08/services/shopping/rest.openapi.json" + }, + "x-ucp-spec-urls": { + "catalog": "https://ucp.dev/2026-04-08/specification/catalog", + "checkout": "https://ucp.dev/2026-04-08/specification/checkout", + "fulfillment": "https://ucp.dev/2026-04-08/specification/fulfillment", + "order": "https://ucp.dev/2026-04-08/specification/order", + "overview": "https://ucp.dev/2026-04-08/specification/overview" + }, + "x-ucp-version": "2026-04-08", + "x-visibility": { + "extent": "public" + } + }, + "openapi": "3.0.3", + "paths": { + "/v1/shopping/catalog/lookup": { + "post": { + "description": "UCP-native `dev.ucp.shopping.catalog.lookup` capability. Each\nreturned variant carries `inputs[]` correlating it back to the\nrequested identifier(s) and how the match was resolved (`exact` or\n`featured`). Buyer auth is optional here, same as `search_catalog`\n— see \"Direct Checkout and identity linking\" above.\n", + "operationId": "lookup_catalog", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "example": { + "context": { + "currency": "USD" + }, + "ids": [ + "nes-wsb-vnext-tier1" + ] + }, + "schema": { + "$ref": "#/components/schemas/lookup_request" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "products": [ + { + "categories": [ + { + "value": "websiteBuilder" + } + ], + "description": { + "plain": "Website Builder - Basic" + }, + "id": "nes-wsb-vnext-tier1", + "price_range": { + "max": { + "amount": 999, + "currency": "USD" + }, + "min": { + "amount": 999, + "currency": "USD" + } + }, + "title": "Website Builder - Basic", + "variants": [ + { + "availability": { + "available": true + }, + "description": { + "plain": "Website Builder - Basic" + }, + "id": "nes-wsb-vnext-tier1", + "inputs": [ + { + "id": "nes-wsb-vnext-tier1", + "match": "exact" + } + ], + "metadata": { + "availability": "IN_STOCK" + }, + "price": { + "amount": 999, + "currency": "USD" + }, + "title": "Website Builder - Basic" + } + ] + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.catalog.lookup": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + }, + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/lookup_response" + }, + { + "$ref": "#/components/schemas/error_response" + } + ] + } + } + }, + "description": "Lookup results (or a business-logic error)." + } + }, + "security": [ + { + "oauth2": [ + "shopping.catalog:read" + ] + } + ], + "summary": "Batch lookup of products/variants by identifier", + "tags": [ + "Catalog" + ] + } + }, + "/v1/shopping/catalog/product": { + "post": { + "description": "UCP-native single-resource lookup (`get_product` in the canonical\nREST binding). Optionally supports partial option `selected[]` +\n`preferences[]` relaxation-order narrowing (returning\n`detail_option_value` entries with `available`/`exists`) for\nproducts that declare native `product.options`/`variant.options`\naxes. A static source record uses those axes only for values fixed\nby the selected priced variant. `variant.input_schema` is reserved\nfor configuration that remains open after variant selection; its\ncorresponding submitted value is `line_item.input`. This target-state\n\nA dynamic domain offer is first discovered through the negotiated\n`com.godaddy.shopping.domain` concept's `action_required` contract, which\nidentifies `search_catalog` and its complete domain search request schema.\nThe selected returned purchasable variant is then used directly as\n`item.id`, with required `item.category: domain` routing discriminator;\ndo not resend the selected FQDN in `line_item.input` unless a future\nselected-variant schema explicitly requires it. Price and availability\nare obtained from the authoritative domain catalog. Domain offer identity remains an open design decision.\n", + "operationId": "get_product", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "example": { + "id": "nes-cpanel-set-2-deluxe-365-wss-xtra" + }, + "schema": { + "$ref": "#/components/schemas/get_product_request" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "static_term_variants": { + "summary": "get_product response — source-backed static term variants", + "value": { + "product": { + "description": { + "plain": "cPanel Linux web hosting on the Deluxe plan, bundled with a Microsoft 365 email mailbox and GoDaddy Website Security (SSL included) on annual terms. The 1-month term is hosting only." + }, + "id": "nes-cpanel-set-2-deluxe-365-wss-xtra", + "price_range": { + "max": { + "amount": 1999, + "currency": "USD" + }, + "min": { + "amount": 799, + "currency": "USD" + } + }, + "title": "Web Hosting Deluxe", + "variants": [ + { + "availability": { + "available": true + }, + "description": { + "plain": "Deluxe web hosting on a 3-year term. Includes Microsoft 365 email and Website Security with SSL." + }, + "id": "nes-cpanel-set-2-deluxe-365-wss-xtra:3yr", + "included_products": [ + { + "description": { + "plain": "Deluxe cPanel Linux web hosting for up to 10 websites." + }, + "id": "hosting", + "price": { + "amount": 0, + "currency": "USD" + }, + "quantity": 1, + "title": "Web Hosting Deluxe" + }, + { + "description": { + "plain": "Three Microsoft 365 Email Essentials mailboxes included for 12 months." + }, + "id": "microsoft-365-email-essentials-trial", + "included_period": { + "count": 12, + "unit": "month" + }, + "list_price": { + "amount": 32364, + "currency": "USD" + }, + "price": { + "amount": 0, + "currency": "USD" + }, + "quantity": 3, + "tags": [ + "10 GB email storage", + "Email, calendar, and contacts synchronized across devices" + ], + "title": "Microsoft 365 Email Essentials Free Trial" + }, + { + "description": { + "plain": "Website Security Standard with SSL, WAF, CDN, malware removal, and a site seal." + }, + "id": "website-security-standard", + "list_price": { + "amount": 1298, + "currency": "USD" + }, + "price": { + "amount": 0, + "currency": "USD" + }, + "quantity": 1, + "tags": [ + "Web application firewall and CDN", + "Malware removal and site seal" + ], + "title": "Website Security Standard" + } + ], + "price": { + "amount": 799, + "currency": "USD" + }, + "title": "Web Hosting Deluxe — 3 Year" + }, + { + "availability": { + "available": true + }, + "description": { + "plain": "Deluxe web hosting on a 1-year term. Includes Microsoft 365 email and Website Security with SSL." + }, + "id": "nes-cpanel-set-2-deluxe-365-wss-xtra:1yr", + "price": { + "amount": 999, + "currency": "USD" + }, + "title": "Web Hosting Deluxe — 1 Year" + }, + { + "availability": { + "available": true + }, + "description": { + "plain": "Deluxe web hosting on a 2-year term. Includes Microsoft 365 email and Website Security with SSL." + }, + "id": "nes-cpanel-set-2-deluxe-365-wss-xtra:2yr", + "price": { + "amount": 899, + "currency": "USD" + }, + "title": "Web Hosting Deluxe — 2 Year" + }, + { + "availability": { + "available": true + }, + "description": { + "plain": "Deluxe web hosting on a 1-month term. Hosting only — Microsoft 365 email and Website Security are included on annual terms." + }, + "id": "nes-cpanel-set-2-deluxe-365-wss-xtra:1mo", + "price": { + "amount": 1999, + "currency": "USD" + }, + "title": "Web Hosting Deluxe — 1 Month" + } + ] + }, + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.catalog.lookup": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + } + } + }, + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/get_product_response" + }, + { + "$ref": "#/components/schemas/error_response" + } + ] + } + } + }, + "description": "Product detail (or a business-logic error)." + } + }, + "security": [ + { + "oauth2": [ + "shopping.catalog:read" + ] + } + ], + "summary": "Single-product detail", + "tags": [ + "Catalog" + ] + } + }, + "/v1/shopping/catalog/search": { + "post": { + "description": "UCP-native `dev.ucp.shopping.catalog.search` capability\n(`search_catalog` in the canonical REST binding). Returns products\nwhose products and variants may carry GoDaddy-extension fields\n(`input_schema`, `renewal_price`, `tier`/`plan`) when the calling\nplatform has negotiated the corresponding extension capability — see\n\"Capability negotiation\" above. Buyer auth is optional here: linking a\nbuyer first returns customer-specific catalog/pricing when available,\nbut anonymous search is supported.\n\nNegotiated `com.godaddy.shopping.domain` discovery uses the explicit\n`domains[]` request defined by its reusable schema, not `query`, and\ninvokes this operation. Results may include relevant alternative domain\nProducts in addition to requested-domain matches; every Product identifies\nwhether it is a requested match or a suggestion and carries its own current\navailability and price. Unavailable/error requested candidates are\ncorrelated through `messages[]`.\nA non-purchasable concept uses the separately negotiated\n`com.godaddy.shopping.catalog_action` contract to identify this operation\nand its complete request schema. Target purchase responses use returned purchasable product/variant IDs; the\nDomain result IDs and prices in examples are\nillustrative protocol values only; production values come from Catalog\nQuery.\n", + "operationId": "search_catalog", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "examples": { + "browse_offerings": { + "summary": "List the small set of offerings enabled for this channel", + "value": { + "context": { + "address_country": "US", + "currency": "USD", + "language": "en" + }, + "pagination": { + "limit": 20 + } + } + }, + "domain_candidates": { + "summary": "Resolve explicit candidate FQDNs through Catalog Query", + "value": { + "domains": [ + "example.com", + "unavailable.example" + ] + } + } + }, + "schema": { + "$ref": "#/components/schemas/search_request" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "browse_offerings": { + "summary": "Representative purchasable products plus the domain-registration concept", + "value": { + "pagination": { + "has_next_page": false + }, + "products": [ + { + "description": { + "plain": "Build and publish a website." + }, + "id": "nes-wsb-vnext-tier1", + "price_range": { + "max": { + "amount": 999, + "currency": "USD" + }, + "min": { + "amount": 999, + "currency": "USD" + } + }, + "title": "Websites + Marketing Basic", + "variants": [ + { + "availability": { + "available": true + }, + "description": { + "plain": "Basic website plan." + }, + "id": "nes-wsb-vnext-tier1", + "price": { + "amount": 999, + "currency": "USD" + }, + "title": "Basic" + } + ] + }, + { + "action_required": { + "capability": "com.godaddy.shopping.domain", + "hint": "Provide one or more complete domain names in domains to retrieve current availability, pricing, and purchasable domain products. This 'action_required' product can't be directly purchased through checkout.", + "input_schema": { + "$ref": "#/components/schemas/domain-discovery-search-request_schema" + }, + "operation_id": "search_catalog" + }, + "categories": [ + { + "value": "domain" + } + ], + "description": { + "plain": "Search explicit fully qualified domain names to get current availability, price, and purchasable domain products." + }, + "id": "domain-registration", + "price_range": { + "max": { + "amount": 0, + "currency": "USD" + }, + "min": { + "amount": 0, + "currency": "USD" + } + }, + "title": "Domain registration", + "variants": [ + { + "availability": { + "available": false + }, + "description": { + "plain": "Search guidance only; not purchasable." + }, + "id": "domain-registration-search", + "price": { + "amount": 0, + "currency": "USD" + }, + "title": "Search for a domain" + } + ] + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_action": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.domain": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.catalog.search": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + } + }, + "domain_candidates": { + "summary": "Final requested and suggested domain Products plus unavailable-candidate messages", + "value": { + "messages": [ + { + "code": "domain_unavailable", + "content": "unavailable.example is not currently available for registration.", + "content_type": "plain", + "path": "$.domains[1]", + "type": "info" + } + ], + "pagination": { + "has_next_page": false + }, + "products": [ + { + "categories": [ + { + "value": "domain" + } + ], + "description": { + "plain": "Current availability and price for the requested FQDN." + }, + "domain_search_result": { + "relationship": "requested", + "requested_domain": "example.com" + }, + "id": "domain-product-example-com", + "price_range": { + "max": { + "amount": 1299, + "currency": "USD" + }, + "min": { + "amount": 1299, + "currency": "USD" + } + }, + "title": "example.com", + "variants": [ + { + "availability": { + "available": true + }, + "description": { + "plain": "Purchasable domain registration result." + }, + "id": "domain-variant-example-com", + "price": { + "amount": 1299, + "currency": "USD" + }, + "title": "example.com" + } + ] + }, + { + "categories": [ + { + "value": "domain" + } + ], + "description": { + "plain": "Suggested alternative with its own current availability and price." + }, + "domain_search_result": { + "relationship": "suggestion", + "requested_domain": "example.com" + }, + "id": "domain-product-example-net", + "price_range": { + "max": { + "amount": 1499, + "currency": "USD" + }, + "min": { + "amount": 1499, + "currency": "USD" + } + }, + "title": "example.net", + "variants": [ + { + "availability": { + "available": true + }, + "description": { + "plain": "Purchasable suggested domain registration result." + }, + "id": "domain-variant-example-net", + "price": { + "amount": 1499, + "currency": "USD" + }, + "title": "example.net" + } + ] + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_action": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.domain": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.catalog.search": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + } + } + }, + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/search_response" + }, + { + "$ref": "#/components/schemas/error_response" + } + ] + } + } + }, + "description": "Search results (or a business-logic error)." + } + }, + "security": [ + { + "oauth2": [ + "shopping.catalog:read" + ] + } + ], + "summary": "Free-text/filtered product search", + "tags": [ + "Catalog" + ] + } + }, + "/v1/shopping/checkout-sessions": { + "post": { + "description": "UCP-native `dev.ucp.shopping.checkout` capability. Version 1 creates\nCheckout directly with required `line_items[]` containing selected\nVariant IDs; no Cart conversion is advertised. `buyer`/`context` are\noptional (a buyer may be linked now, or left anonymous and linked\nlater, any time before `complete_checkout`). For an authenticated buyer,\nthe response returns all eligible saved payment instruments in canonical\ndeterministic handler order. If `payment` is omitted, the first eligible\ninstrument is selected. If `payment` explicitly selects an instrument,\nit must be owned by the buyer and eligible; invalid explicit selection\nproduces a recoverable error and never silently falls back. The response\n`status` reflects the checkout state machine (`incomplete`,\n`requires_escalation`, `ready_for_complete`,\n`complete_in_progress`, `completed`, `canceled`); `messages[]`\nwith `requires_buyer_input`/`requires_buyer_review` severity drive\n`requires_escalation`, in which case `continue_url` MUST be\npresent. A line item's extension `input` MUST validate against the\nselected variant's `input_schema`. A missing required value or\ninvalid input is rejected with a `recoverable` message and the\nsession stays `incomplete` — see `update_checkout` below for the\nsame rule on subsequent adds.\n", + "operationId": "create_checkout", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "examples": { + "curated_hosting_default_payment": { + "summary": "Create from a known catalog-offer Variant and default the first eligible saved payment profile", + "value": { + "line_items": [ + { + "item": { + "id": "nes-cpanel-set-2-deluxe-365-wss-xtra:3yr" + }, + "quantity": 1 + } + ] + } + }, + "curated_hosting_explicit_payment": { + "summary": "Create from a known catalog-offer Variant with an explicit saved payment profile", + "value": { + "line_items": [ + { + "item": { + "id": "nes-cpanel-set-2-deluxe-365-wss-xtra:3yr" + }, + "quantity": 1 + } + ], + "payment": { + "instruments": [ + { + "handler_id": "com.godaddy.payments", + "id": "profile_mastercard", + "selected": true, + "type": "card" + } + ] + } + } + }, + "dynamic_domain_target": { + "summary": "Target-state dynamic-domain checkout with validated input", + "value": { + "line_items": [ + { + "item": { + "category": "domain", + "id": "domain-variant-example-com" + }, + "quantity": 1 + } + ] + } + }, + "website_builder": { + "summary": "Runtime-supported checkout shape for a static catalog variant", + "value": { + "line_items": [ + { + "item": { + "id": "nes-wsb-vnext-tier1" + }, + "quantity": 1 + } + ] + } + } + }, + "schema": { + "$ref": "#/components/schemas/checkout_writable_request" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "examples": { + "incomplete": { + "summary": "Static Website Builder checkout incomplete for a non-payment requirement", + "value": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + } + }, + "invalid_explicit_payment": { + "summary": "Explicit saved payment profile is unavailable or ineligible", + "value": { + "messages": [ + { + "code": "payment_instrument_invalid", + "content": "The selected saved payment profile is unavailable or ineligible for this Checkout.", + "path": "$.payment.instruments[0].id", + "severity": "recoverable", + "type": "error" + } + ], + "ucp": { + "status": "error", + "version": "2026-04-08" + } + } + }, + "missing_required_input": { + "summary": "Generic target-state missing selected-variant configuration", + "value": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "messages": [ + { + "code": "missing_required_input", + "content": "Required selected-variant input is missing.", + "path": "$.line_items[0].input", + "severity": "recoverable", + "type": "error" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 0, + "type": "subtotal" + }, + { + "amount": 0, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + } + }, + "requires_escalation": { + "summary": "Checkout requiring an out-of-band saved payment method", + "value": { + "<<": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + }, + "continue_url": "https://account.godaddy.com/payment-methods/add-payment", + "messages": [ + { + "code": "payment_instrument_required", + "content": "No eligible saved payment method is available. Add one through the GoDaddy account payment-method page, then retrieve this Checkout again to refresh payment.instruments.", + "path": "$.payment", + "severity": "requires_buyer_input", + "type": "error" + } + ], + "payment": { + "instruments": [] + }, + "status": "requires_escalation" + } + } + }, + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/checkout" + }, + { + "$ref": "#/components/schemas/error_response" + } + ] + } + } + }, + "description": "Checkout session created (or a business-logic error)." + } + }, + "security": [ + { + "oauth2": [ + "shopping.checkout:execute" + ] + } + ], + "summary": "Create a checkout session", + "tags": [ + "Checkout" + ] + } + }, + "/v1/shopping/checkout-sessions/{id}": { + "get": { + "operationId": "get_checkout", + "parameters": [ + { + "description": "The unique identifier of the checkout session.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "requires_escalation": { + "summary": "Checkout requiring an out-of-band saved payment method", + "value": { + "<<": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + }, + "continue_url": "https://account.godaddy.com/payment-methods/add-payment", + "messages": [ + { + "code": "payment_instrument_required", + "content": "No eligible saved payment method is available. Add one through the GoDaddy account payment-method page, then retrieve this Checkout again to refresh payment.instruments.", + "path": "$.payment", + "severity": "requires_buyer_input", + "type": "error" + } + ], + "payment": { + "instruments": [] + }, + "status": "requires_escalation" + } + } + }, + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/checkout" + }, + { + "$ref": "#/components/schemas/error_response" + } + ] + } + } + }, + "description": "Checkout session (or a business-logic error)." + } + }, + "security": [ + { + "oauth2": [ + "shopping.checkout:execute" + ] + } + ], + "summary": "Retrieve a checkout session", + "tags": [ + "Checkout" + ] + }, + "put": { + "description": "Canonical REST binding names this `update_checkout` and binds it\nto **PUT** (idempotent replace of the writable fields), not PATCH.\n`line_items[]` is required on update, same as create; fields\nmarked `ucp_request: omit`/server-only (e.g. `status`, `totals`,\n`id`) MUST NOT be sent and are ignored/rejected if present. This\nalso adds or removes selected Variants in an existing Checkout by\nresending the complete `line_items[]` collection.\n\n**Enforcement:** the Checkout application validates each line-item\n`input`, when supplied, against the selected variant's `input_schema`.\nA missing required value or invalid value keeps the session `incomplete`\nwith a `recoverable` message. A buyer may also be linked (or changed) on\nthis call, any time before `complete_checkout` — see \"Direct Checkout and\nidentity linking\" above.\n\n**Idempotent replace, not merge:** because `PUT` replaces the\nwritable fields wholesale, each `line_items[]` entry — including\nits `input` (GoDaddy extension field) — MUST be resent in full on every\ncall, even one only adding or changing `payment`. A replacement payment\nselection must identify one eligible saved instrument with\n`selected: true`; the response returns all eligible instruments with\nexactly that one selected. Omitting a previously-set writable value\nclears it; PUT does not merge.\n\n**Unpriceable line items are silently dropped, not a hard\nfailure:** if a submitted line item's product cannot be priced\n(e.g. a stale/unpriceable catalog entry), the business removes it\nfrom the response line_items[] and attaches a `message_warning`\n(`code: line_item_removed`) naming its original position rather\nthan rejecting the whole update; see `checkout_line_item_removed_pricing_failure`.\n", + "operationId": "update_checkout", + "parameters": [ + { + "description": "The unique identifier of the checkout session.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "examples": { + "payment_update": { + "summary": "Select a different eligible saved payment profile while resending full writable state", + "value": { + "line_items": [ + { + "id": "li_hosting_1", + "item": { + "id": "nes-cpanel-set-2-deluxe-365-wss-xtra:3yr" + }, + "quantity": 1 + } + ], + "payment": { + "instruments": [ + { + "handler_id": "com.godaddy.payments", + "id": "profile_mastercard", + "selected": true, + "type": "card" + } + ] + } + } + } + }, + "schema": { + "$ref": "#/components/schemas/checkout_writable_request" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "invalid_explicit_payment": { + "summary": "Explicit saved payment profile is unavailable or ineligible", + "value": { + "messages": [ + { + "code": "payment_instrument_invalid", + "content": "The selected saved payment profile is unavailable or ineligible for this Checkout.", + "path": "$.payment.instruments[0].id", + "severity": "recoverable", + "type": "error" + } + ], + "ucp": { + "status": "error", + "version": "2026-04-08" + } + } + }, + "line_item_removed_pricing_failure": { + "summary": "One submitted item could not be priced and was removed", + "value": { + "<<": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + }, + "messages": [ + { + "code": "line_item_removed", + "content": "This item could not be priced and was removed from your order. The rest of your order is unaffected.", + "path": "$.line_items[1]", + "type": "warning" + } + ] + } + }, + "ready_for_complete": { + "summary": "Static Website Builder checkout ready to complete with the first eligible payment profile selected", + "value": { + "<<": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + }, + "payment": { + "instruments": [ + { + "display": { + "brand": "visa", + "last_digits": "4242" + }, + "handler_id": "com.godaddy.payments", + "id": "profile_visa", + "selected": true, + "type": "card" + }, + { + "display": { + "brand": "mastercard", + "last_digits": "4444" + }, + "handler_id": "com.godaddy.payments", + "id": "profile_mastercard", + "selected": false, + "type": "card" + } + ] + }, + "status": "ready_for_complete" + } + }, + "ready_for_complete_with_discount": { + "summary": "Checkout ready to complete with a dynamically computed discount", + "value": { + "<<": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + }, + "id": "chk_2b7e4", + "payment": { + "instruments": [ + { + "display": { + "brand": "visa", + "last_digits": "4242" + }, + "handler_id": "com.godaddy.payments", + "id": "pi_1", + "selected": true, + "type": "card" + } + ] + }, + "status": "ready_for_complete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": -100, + "lines": [ + { + "amount": -100, + "display_text": "Eligible promotion" + } + ], + "type": "discount" + }, + { + "amount": 899, + "type": "total" + } + ] + } + } + }, + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/checkout" + }, + { + "$ref": "#/components/schemas/error_response" + } + ] + } + } + }, + "description": "Updated checkout session (or a business-logic error)." + } + }, + "security": [ + { + "oauth2": [ + "shopping.checkout:execute" + ] + } + ], + "summary": "Update a checkout session", + "tags": [ + "Checkout" + ] + } + }, + "/v1/shopping/checkout-sessions/{id}/complete": { + "post": { + "description": "Transitions `ready_for_complete` -> `complete_in_progress` ->\n`completed`. `payment` is `required` at complete time (per\n`checkout.json`'s `ucp_request.complete: required`); a linked\nbuyer is also required by this point, whether it was linked at\n`create_checkout` or on any `update_checkout` call up to now —\nsee \"Direct Checkout and identity linking\" above. On success\nthe returned checkout includes `order` (`order_confirmation`:\n`id` + `permalink_url`); the platform then calls\n`GET /orders/{id}` for full order detail.\n\n**This call can also escalate.** If the payment handler returns a\npending/challenge result (for example, 3D Secure), the response is\n`requires_escalation` with a payment-handler-defined `continue_url`\n(see `checkout_requires_escalation_at_complete`). This is distinct\nfrom the out-of-band account page used to add a missing saved payment\nmethod. Poll `get_checkout` after the payment-authentication interaction;\nit resolves to `complete_in_progress` → `completed`, back to\n`ready_for_complete` on failure, or remains `requires_escalation`\nwhile pending.\n", + "operationId": "complete_checkout", + "parameters": [ + { + "description": "The unique identifier of the checkout session.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/checkout_complete_request" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "complete_in_progress": { + "summary": "complete_checkout is in flight", + "value": { + "<<": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + }, + "payment": { + "instruments": [ + { + "display": { + "brand": "visa", + "last_digits": "4242" + }, + "handler_id": "com.godaddy.payments", + "id": "pi_1", + "selected": true, + "type": "card" + } + ] + }, + "status": "complete_in_progress" + } + }, + "completed": { + "summary": "Completed static Website Builder checkout", + "value": { + "<<": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + }, + "order": { + "id": "ord_5c21", + "permalink_url": "https://account.godaddy.com/orders/ord_5c21" + }, + "status": "completed" + } + }, + "requires_escalation_3ds": { + "summary": "complete_checkout requires payment authentication", + "value": { + "<<": { + "currency": "USD", + "id": "chk_9f3a1", + "line_items": [ + { + "id": "li_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": 1, + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "links": [ + { + "type": "privacy_policy", + "url": "https://www.godaddy.com/legal/agreements/privacy-policy" + }, + { + "type": "terms_of_service", + "url": "https://www.godaddy.com/legal/agreements/universal-terms-of-service-agreement" + } + ], + "status": "incomplete", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "com.godaddy.shopping.catalog_offer": [ + { + "version": "2026-04-08" + } + ], + "com.godaddy.shopping.input": [ + { + "version": "2026-04-08" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + }, + "continue_url": "https://checkout.godaddy.com/3ds/chk_9f3a1", + "messages": [ + { + "code": "payment_authentication_required", + "content": "Your bank requires additional verification (3D Secure) to authorize this payment.", + "path": "$.payment", + "severity": "requires_buyer_review", + "type": "error" + } + ], + "payment": { + "instruments": [ + { + "display": { + "brand": "visa", + "last_digits": "4242" + }, + "handler_id": "com.godaddy.payments", + "id": "pi_1", + "selected": true, + "type": "card" + } + ] + }, + "status": "requires_escalation" + } + } + }, + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/checkout" + }, + { + "$ref": "#/components/schemas/error_response" + } + ] + } + } + }, + "description": "Completed checkout session (or a business-logic error)." + } + }, + "security": [ + { + "oauth2": [ + "shopping.checkout:execute" + ] + } + ], + "summary": "Complete a checkout session (create the order)", + "tags": [ + "Checkout" + ] + } + }, + "/v1/shopping/orders/{id}": { + "get": { + "operationId": "get_order", + "parameters": [ + { + "description": "Unique order identifier.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "order_example": { + "summary": "Completed order detail for the static Website Builder variant", + "value": { + "checkout_id": "chk_9f3a1", + "currency": "USD", + "fulfillment": { + "expectations": [ + { + "description": "Digital product fulfillment.", + "fulfillable_on": "now", + "id": "exp_1", + "line_items": [ + { + "id": "oli_1", + "quantity": 1 + } + ], + "method_type": "digital" + } + ] + }, + "id": "ord_5c21", + "line_items": [ + { + "id": "oli_1", + "item": { + "id": "nes-wsb-vnext-tier1", + "price": 999, + "title": "Website Builder - Basic" + }, + "quantity": { + "fulfilled": 1, + "total": 1 + }, + "status": "fulfilled", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ] + } + ], + "permalink_url": "https://account.godaddy.com/orders/ord_5c21", + "totals": [ + { + "amount": 999, + "type": "subtotal" + }, + { + "amount": 999, + "type": "total" + } + ], + "ucp": { + "capabilities": { + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08" + } + ] + }, + "status": "success", + "version": "2026-04-08" + } + } + } + }, + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/order" + }, + { + "$ref": "#/components/schemas/error_response" + } + ] + } + } + }, + "description": "Order detail (or a business-logic error)." + } + }, + "security": [ + { + "oauth2": [ + "shopping.order:read" + ] + } + ], + "summary": "Retrieve an order", + "tags": [ + "Orders" + ] + } + } + }, + "servers": [ + { + "description": "Shopping API host", + "url": "https://api.godaddy.com" + } + ], + "tags": [ + { + "description": "Product search, batch lookup, and single-product detail.", + "name": "Catalog" + }, + { + "description": "Checkout session lifecycle.", + "name": "Checkout" + }, + { + "description": "Post-purchase order retrieval.", + "name": "Orders" + } + ] +} \ No newline at end of file diff --git a/rust/shopping-client/src/lib.rs b/rust/shopping-client/src/lib.rs new file mode 100644 index 00000000..e784848e --- /dev/null +++ b/rust/shopping-client/src/lib.rs @@ -0,0 +1,43 @@ +//! Typed client generated from the vendored Shopping OpenAPI contract. + +mod generated { + #![allow(clippy::all)] + #![allow(dead_code)] + #![allow(unused_imports)] + #![allow(rustdoc::all)] + + include!(concat!(env!("OUT_DIR"), "/codegen.rs")); +} + +pub use generated::*; + +#[derive(Debug, thiserror::Error)] +pub enum BuildError { + #[error("invalid header value: {0}")] + Header(#[from] reqwest::header::InvalidHeaderValue), + #[error("failed to build HTTP client: {0}")] + Http(#[from] reqwest::Error), +} + +/// Builds a generated client whose requests carry the caller's authorization +/// and correlation headers. +pub fn client_with_auth( + base_url: &str, + authorization: &str, + user_agent: &str, + request_id: &str, +) -> Result { + use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue}; + + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, HeaderValue::from_str(authorization)?); + headers.insert( + HeaderName::from_static("x-request-id"), + HeaderValue::from_str(request_id)?, + ); + let http = reqwest::Client::builder() + .user_agent(user_agent) + .default_headers(headers) + .build()?; + Ok(Client::new_with_client(base_url, http)) +} diff --git a/rust/src/domain/common.rs b/rust/src/domain/common.rs index 6b2db862..500c58d8 100644 --- a/rust/src/domain/common.rs +++ b/rust/src/domain/common.rs @@ -46,7 +46,7 @@ fn ensure_transport_observer_registered() { /// Falls back to 2 for an unrecognized code and for the codes ISO marks with no /// minor unit (precious metals `XAU`/`XAG`, the IMF SDR `XDR`, `XXX`, test codes) /// — none of which are spendable currencies that could be a domain price. -fn currency_decimals(code: &str) -> u32 { +pub(crate) fn currency_decimals(code: &str) -> u32 { iso_currency::Currency::from_code(&code.to_ascii_uppercase()) .and_then(|c| c.exponent()) .map_or(2, u32::from) @@ -59,25 +59,64 @@ fn currency_decimals(code: &str) -> u32 { /// explicit and `unsigned_abs` avoids `i64::MIN` overflow. `None` when the amount /// is absent. Missing currency defaults to 2 decimals. pub(super) fn format_money(money: &types::SimpleMoney) -> Option { - let value = money.value?; - let code = money - .currency_code - .as_ref() - .map(|c| c.as_str()) - .unwrap_or(""); - let decimals = currency_decimals(code); + Some(format_minor_units( + money.value?, + money + .currency_code + .as_ref() + .map(|code| code.as_str()) + .unwrap_or(""), + false, + false, + )) +} + +/// Formats an ISO-4217 minor-unit amount using the shared currency dataset. +pub(crate) fn format_minor_units( + value: i64, + currency: &str, + include_currency: bool, + group_whole: bool, +) -> String { + let decimals = currency_decimals(currency); let sign = if value < 0 { "-" } else { "" }; - let abs = value.unsigned_abs(); - if decimals == 0 { - return Some(format!("{sign}{abs}")); - } + let absolute = value.unsigned_abs(); let scale = 10u64.pow(decimals); - Some(format!( - "{sign}{}.{:0width$}", - abs / scale, - abs % scale, - width = decimals as usize - )) + let whole = if group_whole { + grouped_integer(absolute / scale) + } else { + (absolute / scale).to_string() + }; + let amount = if decimals == 0 { + format!("{sign}{whole}") + } else { + format!( + "{sign}{whole}.{:0width$}", + absolute % scale, + width = decimals as usize + ) + }; + if include_currency { + format!("{currency} {amount}") + } else { + amount + } +} + +fn grouped_integer(value: u64) -> String { + let digits = value.to_string(); + let first_group = digits.len() % 3; + let mut output = String::with_capacity(digits.len() + digits.len() / 3); + if first_group > 0 { + output.push_str(&digits[..first_group]); + } + for index in (first_group..digits.len()).step_by(3) { + if !output.is_empty() { + output.push(','); + } + output.push_str(&digits[index..index + 3]); + } + output } /// The entry for a specific year-term period (1, 2, …), or `None` when that term diff --git a/rust/src/domain/mod.rs b/rust/src/domain/mod.rs index fdf21621..905613b3 100644 --- a/rust/src/domain/mod.rs +++ b/rust/src/domain/mod.rs @@ -20,7 +20,7 @@ use cli_engine::{GroupSpec, Module, RuntimeGroupSpec}; mod agreements; mod available; -mod common; +pub(crate) mod common; mod contacts; mod get; mod list; diff --git a/rust/src/next_action.rs b/rust/src/next_action.rs index c90f79ca..a8001d23 100644 --- a/rust/src/next_action.rs +++ b/rust/src/next_action.rs @@ -1,12 +1,9 @@ -//! Helper so every suggested next-step command reads exactly as the user would -//! type it (e.g. `gddy domain quote `, not `domain quote `). +//! Helpers for app-specific next-action construction. //! -//! `cli_engine::NextAction` is a generic, app-agnostic type with no concept of a -//! binary name, so the `gddy` prefix is applied here in the main repo rather than -//! in the (separately versioned) `cli-engine` crate. +//! `cli_engine::NextAction` owns structured parameters and all human rendering; +//! this module only adds the application binary name to command templates. use cli_engine::{NextAction, NextActionParam}; -use serde_json::{Value, json}; use crate::environments::APP_ID; @@ -17,7 +14,7 @@ pub(crate) fn next_action( NextAction::new(format!("{APP_ID} {}", command.into()), description) } -/// Prefill a required next-action param (value + `required: true`). +/// Prefill a required next-action parameter. pub(crate) fn required_value(value: impl Into) -> NextActionParam { NextActionParam { value: Some(value.into()), @@ -26,42 +23,9 @@ pub(crate) fn required_value(value: impl Into) -> NextActionParam { } } -/// Mirrors cli-engine placeholder substitution for custom human views. The -/// structured template and parameters remain in the output envelope. -pub(crate) fn display_command(action: &NextAction) -> String { - action - .params - .iter() - .filter_map(|(name, param)| { - param - .value - .as_ref() - .map(|value| (format!("<{name}>"), value.as_str())) - }) - .fold(action.command.clone(), |command, (placeholder, value)| { - command.replace(&placeholder, value) - }) -} - -pub(crate) fn human_next_steps(actions: &[NextAction]) -> Value { - Value::Array( - actions - .iter() - .map(|action| { - json!({ - "command": display_command(action), - "description": action.description, - }) - }) - .collect(), - ) -} - #[cfg(test)] mod tests { - use cli_engine::NextAction; - - use super::{display_command, required_value}; + use super::required_value; #[test] fn required_value_sets_value_and_required() { @@ -69,12 +33,4 @@ mod tests { assert_eq!(param.value.as_deref(), Some("my-app")); assert!(param.required); } - - #[test] - fn display_command_substitutes_known_parameters() { - let action = NextAction::new("gddy domain get ", "Get a domain") - .with_param("domain", cli_engine::NextActionParam::value("example.com")); - - assert_eq!(display_command(&action), "gddy domain get example.com"); - } } diff --git a/rust/src/shopping/catalog/get.rs b/rust/src/shopping/catalog/get.rs index 716a071c..bd128a4c 100644 --- a/rust/src/shopping/catalog/get.rs +++ b/rust/src/shopping/catalog/get.rs @@ -3,7 +3,7 @@ use cli_engine::{ }; use serde_json::{Value, json}; -use crate::next_action::{human_next_steps, next_action}; +use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::shopping::common::{ client_err, currency_code, make_client, merge_context_currency, read_json, @@ -45,10 +45,9 @@ pub(crate) fn register_human_view(ctx: &mut ModuleContext<'_>) { pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("get", "Get one Shopping catalog product") + CommandSpec::from_args::("get", "View product details") .with_long( - "Get one product or variant with --id. Use --body or --file only for advanced \ - Shopping API selections and preferences.", + "View one product or variant with --id, including available options and prices.", ) .with_system("shopping") .with_tier(Tier::Read) @@ -78,7 +77,7 @@ pub(super) fn command() -> RuntimeCommandSpec { let response = client.catalog_product(body).await.map_err(client_err)?; let actions = next_actions(&response, &ctx.middleware.env); let output = if ctx.middleware.output_format == "human" { - human_response(&response, &actions) + human_response(&response) } else { response }; @@ -122,14 +121,14 @@ fn next_actions(response: &Value, env: &str) -> Vec { env, "checkout create --item --currency ", ), - "Create a checkout with the first available variant", + "Add the first available variant to a cart", ) .with_param("variant-id", NextActionParam::value(variant_id)) .with_param("currency", NextActionParam::value(currency)), ] } -fn human_response(response: &Value, actions: &[cli_engine::NextAction]) -> Value { +fn human_response(response: &Value) -> Value { let product = response.get("product").cloned().unwrap_or(Value::Null); json!({ "id": product.get("id").and_then(Value::as_str).unwrap_or_default(), @@ -138,7 +137,6 @@ fn human_response(response: &Value, actions: &[cli_engine::NextAction]) -> Value "categories": product.get("categories").and_then(Value::as_array).map(|categories| categories.iter().filter_map(|category| category.get("value").and_then(Value::as_str)).collect::>()).unwrap_or_default(), "price_range": product.get("price_range").cloned(), "variants": product.get("variants").cloned().unwrap_or_else(|| json!([])), - "next_steps": human_next_steps(actions), }) } @@ -216,33 +214,9 @@ fn render_human(product: &Value) -> String { money(variant.get("list_price")).unwrap_or_else(|| "Unavailable".to_owned()), )); } - render_next_steps(&mut output, product); output } -fn render_next_steps(output: &mut String, response: &Value) { - let steps = response - .get("next_steps") - .and_then(Value::as_array) - .map(Vec::as_slice) - .unwrap_or_default(); - if steps.is_empty() { - return; - } - output.push_str("\nNext steps:\n"); - for step in steps { - output.push_str(&format!( - " {}\n {}\n", - step.get("command") - .and_then(Value::as_str) - .unwrap_or_default(), - step.get("description") - .and_then(Value::as_str) - .unwrap_or_default(), - )); - } -} - fn money(value: Option<&Value>) -> Option { money::format_value(value) } @@ -264,11 +238,16 @@ mod tests { }, "ucp": {"do_not_render": true} }); - let output = render_human(&human_response(&response, &next_actions(&response, "test"))); + let output = render_human(&human_response(&response)); + let actions = next_actions(&response, "test"); assert!(output.contains("Product (ID: product-1)")); assert!(output.contains("USD 71.88")); - assert!(output.contains("checkout create")); + assert_eq!( + actions[0].description, + "Add the first available variant to a cart" + ); + assert!(actions[0].command.contains("checkout create")); assert!(!output.contains("do_not_render")); } } diff --git a/rust/src/shopping/catalog/lookup.rs b/rust/src/shopping/catalog/lookup.rs index 0e6b5132..f87bc87c 100644 --- a/rust/src/shopping/catalog/lookup.rs +++ b/rust/src/shopping/catalog/lookup.rs @@ -42,11 +42,10 @@ pub(crate) fn register_human_view(ctx: &mut ModuleContext<'_>) { pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("lookup", "Resolve known Shopping catalog IDs") + CommandSpec::from_args::("lookup", "Find products by ID") .with_long( - "Resolve one or more known product or variant IDs with repeatable --id. Unknown IDs \ - are reported in the response messages rather than failing the whole request. Use \ - --body or --file only for advanced Shopping API fields.", + "Find one or more products or variants by ID. Unknown IDs are reported in the response \ + without preventing matches for the other IDs.", ) .with_system("shopping") .with_tier(Tier::Read) diff --git a/rust/src/shopping/catalog/mod.rs b/rust/src/shopping/catalog/mod.rs index bd210648..a4951516 100644 --- a/rust/src/shopping/catalog/mod.rs +++ b/rust/src/shopping/catalog/mod.rs @@ -6,11 +6,8 @@ use cli_engine::{GroupSpec, RuntimeGroupSpec}; pub(super) fn group() -> RuntimeGroupSpec { RuntimeGroupSpec::new( - GroupSpec::new("catalog", "Browse and resolve Shopping catalog products").with_long( - "Search the live Shopping catalog, resolve known product IDs, and retrieve product details. \ - Every command requests the complete Shopping OAuth scope bundle so a catalog-to-checkout \ - workflow needs only one consent flow.", - ), + GroupSpec::new("catalog", "Explore GoDaddy's product catalog") + .with_long("Search GoDaddy's product catalog and retrieve product details."), ) .with_command(search::command()) .with_command(lookup::command()) diff --git a/rust/src/shopping/catalog/search.rs b/rust/src/shopping/catalog/search.rs index bddd3efd..abc95600 100644 --- a/rust/src/shopping/catalog/search.rs +++ b/rust/src/shopping/catalog/search.rs @@ -1,10 +1,10 @@ use cli_engine::{ - CommandResult, CommandSpec, ModuleContext, NextAction, NextActionParam, Result, - RuntimeCommandSpec, Tier, + Alignment, CommandResult, CommandSpec, HumanViewDef, ModuleContext, NextAction, + NextActionParam, Result, RuntimeCommandSpec, TableColumn, Tier, }; use serde_json::{Value, json}; -use crate::next_action::{human_next_steps, next_action}; +use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::shopping::common::{ client_err, currency_code, make_client, merge_context_currency, read_json, @@ -24,8 +24,8 @@ struct Args { #[arg(long, value_name = "TEXT")] query: Option, - /// Product category to include. Repeat to include multiple categories. - #[arg(long, value_name = "CATEGORY")] + /// Product category to include. Supported values: email, pointOfSale, sslCertificate, webHosting, websiteBuilder. Repeat to include multiple categories. + #[arg(long, value_name = "CATEGORY", value_parser = category_value)] category: Vec, /// Opaque cursor from the preceding catalog-search response. @@ -40,10 +40,6 @@ struct Args { #[arg(long, value_name = "CODE", value_parser = currency_code)] currency: Option, - /// Buyer country used for catalog eligibility and pricing. - #[arg(long, value_name = "ISO_COUNTRY_CODE")] - country: Option, - /// Search request as raw JSON for advanced Shopping API filters and extensions. #[arg(long, value_name = "JSON")] body: Option, @@ -57,11 +53,9 @@ pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( CommandSpec::from_args::("search", "Search the Shopping catalog") .with_long( - "Search the Shopping catalog. Omit filters to browse all products. Use --query, \ - repeatable --category, --cursor, --limit, --currency, and --country for common \ - search criteria. Human output groups purchasable variants under each product; \ - --output json returns the unmodified Shopping API response. Use --body or --file \ - only for advanced API filters and extensions.", + "Explore GoDaddy products. Omit filters to browse the catalog. Use --query, repeatable \ + --category, --cursor, --limit, and --currency to refine the results. Use \ + --output json for the complete API response.", ) .with_system("shopping") .with_tier(Tier::Read) @@ -79,7 +73,6 @@ pub(super) fn command() -> RuntimeCommandSpec { args.query.as_deref(), &args.category, args.cursor.as_deref(), - args.country.as_deref(), )?; merge_context_currency(&mut request, args.currency.as_deref())?; merge_pagination(&mut request, args.limit)?; @@ -101,55 +94,60 @@ pub(super) fn command() -> RuntimeCommandSpec { } const HUMAN_VIEW_ID: &str = "shopping-catalog-search"; +const CATEGORIES: &[&str] = &[ + "email", + "pointOfSale", + "sslCertificate", + "webHosting", + "websiteBuilder", +]; + +fn category_value(value: &str) -> std::result::Result { + CATEGORIES + .contains(&value) + .then(|| value.to_owned()) + .ok_or_else(|| format!("category must be one of: {}", CATEGORIES.join(", "))) +} pub(crate) fn register_human_view(ctx: &mut ModuleContext<'_>) { - ctx.middleware_mut() - .human_views - .register_func(HUMAN_VIEW_ID, render_human); + ctx.middleware_mut().human_views.register(HumanViewDef::new( + HUMAN_VIEW_ID, + vec![ + TableColumn::new("product", "Product"), + TableColumn::new("variant", "Variant"), + TableColumn::new("variant_id", "Variant ID").no_truncate(true), + TableColumn::new("category", "Category"), + TableColumn::new("price", "Your Price").align(Alignment::Right), + TableColumn::new("list_price", "List Price").align(Alignment::Right), + TableColumn::new("term", "Term"), + TableColumn::new("availability", "Availability"), + ], + )); } -fn human_response(response: &Value, actions: &[NextAction]) -> Value { - let products = response - .get("products") - .and_then(Value::as_array) - .map(Vec::as_slice) - .unwrap_or_default(); - let variant_count = products - .iter() - .map(|product| purchasable_variants(product).len()) - .sum::(); - let total = response - .pointer("/pagination/total_count") - .and_then(Value::as_u64) - .unwrap_or(products.len() as u64); - json!({ - "summary": format!( - "Showing {} of {total} products · {variant_count} purchasable variants", - products.len() - ), - "products": products - .iter() - .enumerate() - .map(|(index, product)| json!({ - "number": index + 1, - "id": product.get("id").and_then(Value::as_str).unwrap_or_default(), - "title": product.get("title").and_then(Value::as_str).unwrap_or("Untitled product"), - "variants": purchasable_variants(product) - .iter() - .map(|variant| json!({ - "id": variant.get("id").and_then(Value::as_str).unwrap_or_default(), - "title": variant.get("title").and_then(Value::as_str).unwrap_or_default(), +fn human_response(response: &Value, _actions: &[NextAction]) -> Value { + Value::Array( + response + .get("products") + .and_then(Value::as_array) + .into_iter() + .flatten() + .flat_map(|product| { + purchasable_variants(product).into_iter().map(move |variant| { + json!({ + "product": product.get("title").and_then(Value::as_str).unwrap_or("Untitled product"), + "variant": variant.get("title").and_then(Value::as_str).unwrap_or("Untitled variant"), + "variant_id": variant.get("id").and_then(Value::as_str).unwrap_or_default(), "category": category(product), "price": money(variant.get("price")), "list_price": money(variant.get("list_price")), "term": term(variant), "availability": availability(variant), - })) - .collect::>(), - })) - .collect::>(), - "next_steps": human_next_steps(actions), - }) + }) + }) + }) + .collect(), + ) } fn merge_search_args( @@ -157,7 +155,6 @@ fn merge_search_args( query: Option<&str>, categories: &[String], cursor: Option<&str>, - country: Option<&str>, ) -> Result<()> { let object = request .as_object_mut() @@ -185,13 +182,6 @@ fn merge_search_args( })?; merge_string(pagination, "cursor", cursor, "--cursor")?; } - if let Some(country) = country { - let context = object.entry("context").or_insert_with(|| json!({})); - let context = context.as_object_mut().ok_or_else(|| { - crate::error::GddyError::validation("context must be a JSON object").into_cli_error() - })?; - merge_string(context, "address_country", country, "--country")?; - } Ok(()) } @@ -274,7 +264,7 @@ fn product_actions(response: &Value, request: &Value, env: &str) -> Vec"), - "View the selected product's complete record", + "View the selected product's details", ) .with_param("product-id", NextActionParam::value(product_id)); if let Some(currency) = currency { @@ -300,7 +290,7 @@ fn product_actions(response: &Value, request: &Value, env: &str) -> Vec --currency ", ), - "Create a checkout with the first available variant", + "Add the first available variant to a cart", ) .with_param("variant-id", NextActionParam::value(variant_id)) .with_param("currency", NextActionParam::value(currency)), @@ -390,12 +380,6 @@ fn search_action(request: &serde_json::Map, env: &str) -> Result< "currency", context.and_then(|context| context.get("currency")), ); - append_search_param( - &mut command, - &mut params, - "country", - context.and_then(|context| context.get("address_country")), - ); Ok(params.into_iter().fold( next_action(command_for_env(env, command), "Fetch the next catalog page"), |action, (name, value)| action.with_param(name, NextActionParam::value(value)), @@ -433,63 +417,7 @@ fn is_simple_search_request(request: &serde_json::Map) -> bool { && request .get("context") .and_then(Value::as_object) - .is_none_or(|context| { - context - .keys() - .all(|key| key == "currency" || key == "address_country") - }) -} - -fn render_human(response: &Value) -> String { - let mut output = response - .get("summary") - .and_then(Value::as_str) - .map_or_else(String::new, |summary| format!("{summary}\n")); - for product in response - .get("products") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - output.push('\n'); - output.push_str(&format!( - "{}. {} (ID: {})\n{}\n", - product - .get("number") - .and_then(Value::as_u64) - .unwrap_or_default(), - product - .get("title") - .and_then(Value::as_str) - .unwrap_or("Untitled product"), - product - .get("id") - .and_then(Value::as_str) - .unwrap_or_default(), - "─".repeat(72) - )); - output.push_str(&render_variants_table(product)); - } - let next_steps = response - .get("next_steps") - .and_then(Value::as_array) - .map(Vec::as_slice) - .unwrap_or_default(); - if !next_steps.is_empty() { - output.push_str("\nNext steps:\n"); - for step in next_steps { - let command = step - .get("command") - .and_then(Value::as_str) - .unwrap_or_default(); - let description = step - .get("description") - .and_then(Value::as_str) - .unwrap_or_default(); - output.push_str(&format!(" {command}\n {description}\n")); - } - } - output + .is_none_or(|context| context.keys().all(|key| key == "currency")) } fn term(variant: &Value) -> String { @@ -507,114 +435,6 @@ fn term(variant: &Value) -> String { .unwrap_or_default() } -fn render_variants_table(product: &Value) -> String { - let rows = product - .get("variants") - .and_then(Value::as_array) - .into_iter() - .flatten() - .map(|variant| { - vec![ - variant - .get("id") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(), - variant - .get("title") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(), - variant - .get("category") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(), - variant - .get("price") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(), - variant - .get("list_price") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(), - variant - .get("term") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(), - variant - .get("availability") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(), - ] - }) - .collect::>(); - render_table( - &[ - "ID", - "Description", - "Category", - "Your Price", - "List Price", - "Term", - "Availability", - ], - &rows, - &[false, false, false, true, true, false, false], - ) -} - -fn render_table(headers: &[&str], rows: &[Vec], right_aligned: &[bool]) -> String { - if rows.is_empty() { - return "No purchasable variants returned.\n".to_owned(); - } - let widths = headers - .iter() - .enumerate() - .map(|(index, header)| { - rows.iter() - .filter_map(|row| row.get(index)) - .map(String::len) - .max() - .unwrap_or_default() - .max(header.len()) - }) - .collect::>(); - let mut output = format_row( - headers.iter().map(|header| (*header).to_owned()).collect(), - &widths, - right_aligned, - ); - output.push_str(&format_row( - widths.iter().map(|width| "-".repeat(*width)).collect(), - &widths, - right_aligned, - )); - for row in rows { - output.push_str(&format_row(row.clone(), &widths, right_aligned)); - } - output -} - -fn format_row(values: Vec, widths: &[usize], right_aligned: &[bool]) -> String { - let cells = values - .iter() - .enumerate() - .map(|(index, value)| { - if right_aligned.get(index).copied().unwrap_or(false) { - format!("{value:>width$}", width = widths[index]) - } else { - format!("{value:>(); - format!("{}\n", cells.join(" ")) -} - fn purchasable_variants(product: &Value) -> Vec<&Value> { product .get("variants") @@ -660,10 +480,9 @@ mod tests { use serde_json::json; use super::{ - human_response, merge_pagination, merge_search_args, next_actions, render_human, + category_value, human_response, merge_pagination, merge_search_args, next_actions, search_action, validate_price_filter_currency, }; - use crate::shopping::command_for_env; use crate::shopping::common::{currency_code, merge_context_currency}; fn response() -> serde_json::Value { @@ -693,7 +512,6 @@ mod tests { Some("email"), &["email".to_owned(), "hosting".to_owned()], Some("cursor-1"), - Some("GB"), ) .expect("flags should merge"); merge_context_currency(&mut request, Some("GBP")).expect("currency should merge"); @@ -705,7 +523,7 @@ mod tests { "query": "email", "filters": {"categories": ["email", "hosting"]}, "pagination": {"cursor": "cursor-1", "limit": 3}, - "context": {"address_country": "GB", "currency": "GBP"} + "context": {"currency": "GBP"} }) ); } @@ -743,11 +561,11 @@ mod tests { assert!( actions .iter() - .all(|action| action.command.contains("gddy --env test")) + .all(|action| action.command.starts_with("gddy shopping")) ); assert_eq!( actions[0].command, - "gddy --env test shopping catalog get --id " + "gddy shopping catalog get --id " ); assert_eq!( actions[0].params["product-id"].value.as_deref(), @@ -755,7 +573,7 @@ mod tests { ); assert_eq!( actions[1].command, - "gddy --env test shopping checkout create --item --currency " + "gddy shopping checkout create --item --currency " ); assert_eq!( actions[1].params["variant-id"].value.as_deref(), @@ -764,7 +582,7 @@ mod tests { assert_eq!(actions[2].params["cursor"].value.as_deref(), Some("next")); assert_eq!( actions[2].command, - "gddy --env test shopping catalog search --cursor --limit 3" + "gddy shopping catalog search --cursor --limit 3" ); } @@ -803,10 +621,7 @@ mod tests { ) .expect("action"); - assert_eq!( - action.command, - "gddy --env test shopping catalog search --body " - ); + assert_eq!(action.command, "gddy shopping catalog search --body "); assert_eq!( action.params["body"].value.as_deref(), Some(r#"{"signals":{"value":"O'Reilly"}}"#) @@ -817,36 +632,12 @@ mod tests { fn human_output_groups_variants_by_product_with_summary() { let response = response(); let actions = next_actions(&response, &mut json!({}), "test").expect("actions"); - let rendered = render_human(&human_response(&response, &actions)); - assert!(rendered.contains("Showing 1 of 14 products · 1 purchasable variants")); - assert!( - rendered.contains("1. Product (ID: product-1)"), - "{rendered}" - ); - assert!(!rendered.contains("PRODUCT ID")); - assert!( - rendered.contains("ID Description"), - "{rendered}" - ); - assert!(rendered.contains("USD 71.88")); - assert!(rendered.contains("Available")); - assert!(rendered.contains("Next steps:"), "{rendered}"); - assert!( - rendered.contains("gddy --env test shopping catalog get"), - "{rendered}" - ); - } - - #[test] - fn product_commands_preserve_non_production_environment() { - assert_eq!( - command_for_env("prod", "catalog search"), - "shopping catalog search" - ); - assert_eq!( - command_for_env("test", "catalog search"), - "--env test shopping catalog search" - ); + let rows = human_response(&response, &actions); + assert_eq!(rows.as_array().map(Vec::len), Some(1)); + assert_eq!(rows[0]["product"], "Product"); + assert_eq!(rows[0]["variant_id"], "product-1:1yr"); + assert_eq!(rows[0]["price"], "USD 71.88"); + assert_eq!(rows[0]["availability"], "Available"); } #[test] @@ -867,6 +658,12 @@ mod tests { assert!(merge_context_currency(&mut request, Some("JPY")).is_err()); } + #[test] + fn validates_api_derived_non_domain_categories() { + assert_eq!(category_value("webHosting"), Ok("webHosting".to_owned())); + assert!(category_value("domain").is_err()); + } + #[test] fn validates_and_normalizes_currency_codes() { assert_eq!(currency_code(" gbp ").expect("valid currency"), "GBP"); diff --git a/rust/src/shopping/checkout/complete.rs b/rust/src/shopping/checkout/complete.rs index d59a9777..3e76da0b 100644 --- a/rust/src/shopping/checkout/complete.rs +++ b/rust/src/shopping/checkout/complete.rs @@ -3,27 +3,27 @@ use cli_engine::{ }; use serde_json::{Value, json}; -use crate::next_action::{human_next_steps, next_action}; +use crate::next_action::next_action; +use crate::shopping::SHOPPING_SCOPES; use crate::shopping::client::ClientError; use crate::shopping::common::{ - CheckoutInput, ensure_completion_idempotency_key, has_conflicting_checkout_id, make_client, - read_json, reject_mixed_checkout_input, require_selected_payment_instrument, + CheckoutInput, has_conflicting_checkout_id, make_client, read_json, + reject_mixed_checkout_input, require_selected_payment_instrument, }; -use crate::shopping::{SHOPPING_SCOPES, command_for_env}; #[derive(Debug, Clone, clap::Args)] struct Args { - /// Checkout session ID. + /// Cart ID. #[arg(value_name = "CHECKOUT_ID")] id: String, - /// Saved payment instrument ID to use for this purchase. + /// Saved payment method ID to use for this purchase. #[arg(long, value_name = "INSTRUMENT_ID")] payment_instrument: Option, - /// Stable key for this single intended purchase. A UUID is generated when omitted. - #[arg(long, value_name = "KEY")] - idempotency_key: Option, + /// Acknowledge the cart's terms and other important links. + #[arg(long)] + agree: bool, /// Completion request as raw JSON for advanced payment or billing-address fields. #[arg(long, value_name = "JSON")] @@ -42,13 +42,10 @@ pub(crate) fn register_human_view(ctx: &mut ModuleContext<'_>) { .register_func(HUMAN_VIEW_ID, render_human); } -fn human_response( - completion: &Value, - idempotency_key: &str, - actions: &[cli_engine::NextAction], -) -> Value { +// The transport key is an implementation detail, not customer-facing output. +fn human_response(completion: &Value) -> Value { json!({ - "checkout_id": completion.get("id").and_then(Value::as_str).unwrap_or_default(), + "cart_id": completion.get("id").and_then(Value::as_str).unwrap_or_default(), "status": completion.get("status").and_then(Value::as_str).unwrap_or_default(), "order_id": completion.pointer("/order/id").and_then(Value::as_str), "order_permalink": completion.pointer("/order/permalink_url").and_then(Value::as_str), @@ -56,42 +53,29 @@ fn human_response( completion.get("totals"), completion.get("currency").and_then(Value::as_str), ), - "idempotency_key": idempotency_key, - "next_steps": human_next_steps(actions), }) } fn render_human(completion: &Value) -> String { if let Some(action) = completion.get("action").and_then(Value::as_str) { - let idempotency_key = completion - .get("idempotency_key") - .and_then(Value::as_str) - .unwrap_or_default(); - let body = completion.get("body").cloned().unwrap_or(Value::Null); return format!( - "{}\nCheckout: {}\nIdempotency key: {idempotency_key}\nRequest:\n{}\n", - action, + "{action}\nCart: {}\n", completion .get("id") .and_then(Value::as_str) .unwrap_or_default(), - serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string()), ); } let mut output = format!( - "Checkout: {}\nStatus: {}\nIdempotency key: {}\n", + "Cart: {}\nStatus: {}\n", completion - .get("checkout_id") + .get("cart_id") .and_then(Value::as_str) .unwrap_or_default(), completion .get("status") .and_then(Value::as_str) .unwrap_or_default(), - completion - .get("idempotency_key") - .and_then(Value::as_str) - .unwrap_or_default(), ); if let Some(order_id) = completion.get("order_id").and_then(Value::as_str) { output.push_str(&format!("Order: {order_id}\n")); @@ -102,28 +86,40 @@ fn render_human(completion: &Value) -> String { if let Some(total) = completion.get("total").and_then(Value::as_str) { output.push_str(&format!("Total: {total}\n")); } - output.push_str("\nKeep this idempotency key. Do not retry a completion unless you first confirm its outcome.\n"); - crate::shopping::checkout::get::render_next_steps(&mut output, completion); output } -fn completion_error(error: ClientError, idempotency_key: &str) -> cli_engine::CliCoreError { - crate::error::GddyError::from(error) - .with_fix(format!( - "Completion may have reached Shopping. Do not retry automatically. Reuse idempotency_key \ - {idempotency_key:?} only for the same intended purchase after confirming its outcome." - )) - .into_cli_error() +fn completion_error(error: ClientError) -> cli_engine::CliCoreError { + crate::error::GddyError::from(error).into_cli_error() +} + +fn public_completion_body(body: &Value) -> Value { + let mut body = body.clone(); + body.as_object_mut() + .expect("completion body is an object") + .remove("idempotency_key"); + body +} + +fn agreement_gate(agree: bool) -> cli_engine::Result<()> { + if agree { + return Ok(()); + } + Err(crate::error::GddyError::validation( + "placing an order requires acknowledging the cart's terms and important links", + ) + .with_fix( + "Review the cart with `shopping checkout get `, then re-run with --agree.", + ) + .into_cli_error()) } pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("complete", "Complete a Shopping checkout and place an order") + CommandSpec::from_args::("complete", "Place an order with the contents of your cart") .with_long( - "Places a real order. Use --payment-instrument for one saved payment instrument, \ - or --body/--file for advanced payment or billing-address fields. Use --idempotency-key \ - to control retries, or omit it to let gddy generate and return one. The CLI never retries \ - completion automatically. Read the resulting order with `shopping order get --wait`.", + "Place an order with a selected saved payment method. Review the cart and its links \ + first, then use --agree to acknowledge them.", ) .with_system("shopping") .with_tier(Tier::Mutate) @@ -133,65 +129,58 @@ pub(super) fn command() -> RuntimeCommandSpec { .auth_optional() .with_view_id(HUMAN_VIEW_ID), |ctx, args: Args| async move { + agreement_gate(args.agree)?; + let payment_instrument = args.payment_instrument; let input = CheckoutInput { - payment_instrument: args.payment_instrument, + payment_instrument, ..CheckoutInput::default() }; reject_mixed_checkout_input(args.body.as_deref(), args.file.as_deref(), input.is_present())?; - if args.idempotency_key.as_deref().is_some_and(|key| key.trim().is_empty()) { - return Err(crate::error::GddyError::validation( - "--idempotency-key must be non-empty when supplied", - ) - .into_cli_error()); - } let mut body = if args.body.is_some() || args.file.is_some() { - let mut body = read_json(args.body.as_deref(), args.file.as_deref(), "object")?; - if let Some(idempotency_key) = args.idempotency_key { - body.as_object_mut() - .expect("read_json validates the completion request is an object") - .insert("idempotency_key".to_owned(), json!(idempotency_key)); - } - body + read_json(args.body.as_deref(), args.file.as_deref(), "object")? } else { - input.completion_body(args.idempotency_key.as_deref())? + input.completion_body()? }; if has_conflicting_checkout_id(&body, &args.id) { return Err(crate::error::GddyError::validation( - "checkout ID in request body conflicts with CHECKOUT_ID", + "cart ID in request body conflicts with CHECKOUT_ID", ) .into_cli_error()); } require_selected_payment_instrument(&body)?; - let idempotency_key = ensure_completion_idempotency_key(&mut body)?; + let idempotency_key = uuid::Uuid::new_v4().to_string(); + body.as_object_mut() + .expect("completion body is an object") + .insert( + "idempotency_key".to_owned(), + Value::String(idempotency_key.clone()), + ); if ctx.dry_run() { return Ok(CommandResult::new(json!({ - "action": "dry-run: would complete checkout", + "action": "dry-run: would place order", "id": args.id, - "idempotency_key": idempotency_key, - "body": body, + "body": public_completion_body(&body), })) .with_dry_run()); } let client = make_client(&ctx).await?; - let completion = client.complete_checkout(&args.id, body).await.map_err(|error| { - completion_error(error, &idempotency_key) - })?; - let order_id = completion - .pointer("/order/id") - .and_then(Value::as_str) - .map(str::to_owned); + let completion = client + .complete_checkout(&args.id, body, &idempotency_key) + .await + .map_err(completion_error)?; + let order_id = completion.pointer("/order/id").and_then(Value::as_str); let actions = order_id.map_or_else(Vec::new, |order_id| { vec![ next_action( - command_for_env(&ctx.middleware.env, "order get --wait"), - "Read the completed order after it becomes visible", + "shopping order get --wait", + "Review the order after it becomes visible", ) .with_param("order-id", NextActionParam::value(order_id)), ] }); let output = if ctx.middleware.output_format == "human" { - human_response(&completion, &idempotency_key, &actions) + human_response(&completion) } else { completion }; @@ -204,22 +193,25 @@ pub(super) fn command() -> RuntimeCommandSpec { mod tests { use super::*; + #[test] + fn agreement_gate_requires_agree() { + let error = agreement_gate(false).expect_err("must require --agree"); + assert!(error.to_string().contains("acknowledging")); + assert!(agreement_gate(true).is_ok()); + } + #[test] fn human_view_shows_completion_total_only_with_currency() { - let output = render_human(&human_response( - &json!({ - "id": "checkout-1", - "status": "completed", - "currency": "GBP", - "totals": [ - {"type": "subtotal", "amount": 4788}, - {"type": "tax", "amount": 0}, - {"type": "total", "amount": 4788} - ] - }), - "idempotency-key", - &[], - )); + let output = render_human(&human_response(&json!({ + "id": "checkout-1", + "status": "completed", + "currency": "GBP", + "totals": [ + {"type": "subtotal", "amount": 4788}, + {"type": "tax", "amount": 0}, + {"type": "total", "amount": 4788} + ] + }))); assert!(output.contains("Total: GBP 47.88")); assert!(!output.contains("Subtotal:")); @@ -228,15 +220,11 @@ mod tests { #[test] fn human_view_omits_completion_total_without_currency() { - let output = render_human(&human_response( - &json!({ - "id": "checkout-1", - "status": "completed", - "totals": [{"type": "total", "amount": 4788}] - }), - "idempotency-key", - &[], - )); + let output = render_human(&human_response(&json!({ + "id": "checkout-1", + "status": "completed", + "totals": [{"type": "total", "amount": 4788}] + }))); assert!(!output.contains("Total:")); assert!(!output.contains("4788")); diff --git a/rust/src/shopping/checkout/create.rs b/rust/src/shopping/checkout/create.rs index 486ff969..6fd53a77 100644 --- a/rust/src/shopping/checkout/create.rs +++ b/rust/src/shopping/checkout/create.rs @@ -3,12 +3,12 @@ use serde_json::Value; use crate::next_action::next_action; use crate::output_schema::output_schema; +use crate::shopping::SHOPPING_SCOPES; use crate::shopping::checkout::get::{HUMAN_VIEW_ID, human_response}; use crate::shopping::common::{ CheckoutInput, client_err, currency_code, make_client, no_saved_payment_method_action, read_json, reject_mixed_checkout_input, reject_multiple_payment_instruments, }; -use crate::shopping::{SHOPPING_SCOPES, command_for_env}; output_schema!(CheckoutOutput { "ucp": "object"; @@ -67,12 +67,11 @@ struct Args { pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("create", "Create a Shopping checkout session") + CommandSpec::from_args::("create", "Create a cart") .with_long( - "Create a checkout with --item and optional buyer/payment flags. Repeat --item for \ - multiple variants and append =QUANTITY when needed. Use --body or --file for \ - advanced Shopping API fields such as item input, fulfillment, or billing addresses. \ - This creates a checkout but does not place an order; complete it only after review.", + "Add one or more product variants to a cart. Repeat --item for multiple variants and \ + append =QUANTITY when needed. Creating a cart does not place an order; review its \ + payment methods and links before placing one.", ) .with_system("shopping") .with_tier(Tier::Mutate) @@ -117,11 +116,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .unwrap_or_default() .to_owned(); let env = crate::environments::resolve(&ctx.middleware.env)?; - let mut actions = no_saved_payment_method_action( - &checkout, - &ctx.middleware.env, - &env.account_url, - ) + let mut actions = no_saved_payment_method_action(&checkout, &env.account_url) .into_iter() .collect::>(); if ready_for_complete { @@ -138,11 +133,8 @@ pub(super) fn command() -> RuntimeCommandSpec { .unwrap_or(""); actions.push( next_action( - command_for_env( - &ctx.middleware.env, - "checkout complete --payment-instrument ", - ), - "Complete this checkout with a selected saved payment instrument", + "shopping checkout complete --payment-instrument --agree", + "Place an order after reviewing the cart and its terms", ) .with_param("checkout-id", NextActionParam::value(checkout_id)) .with_param( @@ -152,7 +144,7 @@ pub(super) fn command() -> RuntimeCommandSpec { ); } let output = if ctx.middleware.output_format == "human" { - human_response(&checkout, &actions, args.show_all_payment_instruments) + human_response(&checkout, args.show_all_payment_instruments) } else { checkout }; diff --git a/rust/src/shopping/checkout/get.rs b/rust/src/shopping/checkout/get.rs index 85ba6b45..9485be11 100644 --- a/rust/src/shopping/checkout/get.rs +++ b/rust/src/shopping/checkout/get.rs @@ -1,13 +1,12 @@ use cli_engine::{ - CommandResult, CommandSpec, ModuleContext, NextAction, NextActionParam, Result, - RuntimeCommandSpec, Tier, + CommandResult, CommandSpec, ModuleContext, NextActionParam, Result, RuntimeCommandSpec, Tier, }; use serde_json::{Value, json}; -use crate::next_action::{human_next_steps, next_action}; +use crate::next_action::next_action; +use crate::shopping::SHOPPING_SCOPES; use crate::shopping::common::{client_err, make_client}; use crate::shopping::money; -use crate::shopping::{SHOPPING_SCOPES, command_for_env}; pub(super) const HUMAN_VIEW_ID: &str = "shopping-checkout-get"; @@ -19,22 +18,21 @@ pub(crate) fn register_human_view(ctx: &mut ModuleContext<'_>) { #[derive(Debug, Clone, clap::Args)] struct Args { - /// Checkout session ID. + /// Cart ID. #[arg(value_name = "CHECKOUT_ID")] id: String, - /// Show every available saved payment instrument instead of the first five. + /// Show every available saved payment method instead of the first five. #[arg(long)] show_all_payment_instruments: bool, } pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("get", "Get an open Shopping checkout session") + CommandSpec::from_args::("get", "Review an open cart") .with_long( - "Get an open checkout session. Do not use after completion: completed checkouts \ - cannot be retrieved through this command. Use `shopping order get` with the order \ - ID returned by completion instead.", + "Review an open cart, including its items, available payment methods, and important links. \ + Use the order ID returned after placing an order to review a completed purchase.", ) .with_system("shopping") .with_tier(Tier::Read) @@ -45,15 +43,11 @@ pub(super) fn command() -> RuntimeCommandSpec { let ready_for_complete = checkout.get("status").and_then(Value::as_str) == Some("ready_for_complete"); let actions = if ready_for_complete { - let payment_instrument = - selected_payment_id(&checkout).unwrap_or(""); + let payment_instrument = selected_payment_id(&checkout).unwrap_or(""); vec![ next_action( - command_for_env( - &ctx.middleware.env, - "checkout complete --payment-instrument ", - ), - "Complete this checkout after reviewing its selected payment method", + "shopping checkout complete --payment-instrument --agree", + "Place an order after reviewing the cart and its terms", ) .with_param("checkout-id", NextActionParam::value(args.id)) .with_param( @@ -65,7 +59,7 @@ pub(super) fn command() -> RuntimeCommandSpec { Vec::new() }; let output = if ctx.middleware.output_format == "human" { - human_response(&checkout, &actions, args.show_all_payment_instruments) + human_response(&checkout, args.show_all_payment_instruments) } else { checkout }; @@ -74,16 +68,12 @@ pub(super) fn command() -> RuntimeCommandSpec { ) } -async fn client_response(ctx: &cli_engine::CommandContext, id: &str) -> Result { +pub(super) async fn client_response(ctx: &cli_engine::CommandContext, id: &str) -> Result { let client = make_client(ctx).await?; client.get_checkout(id).await.map_err(client_err) } -pub(super) fn human_response( - checkout: &Value, - actions: &[NextAction], - show_all_payment_instruments: bool, -) -> Value { +pub(super) fn human_response(checkout: &Value, show_all_payment_instruments: bool) -> Value { let line_items = checkout .get("line_items") .and_then(Value::as_array) @@ -104,7 +94,6 @@ pub(super) fn human_response( "id": checkout.get("id").and_then(Value::as_str).unwrap_or_default(), "status": checkout.get("status").and_then(Value::as_str).unwrap_or_default(), "items": line_items, - "currency": checkout.get("currency").and_then(Value::as_str).unwrap_or_default(), "total": money::format_total( checkout.get("totals"), checkout.get("currency").and_then(Value::as_str), @@ -112,7 +101,7 @@ pub(super) fn human_response( "selected_payment": selected_payment(checkout), "available_payment_instruments": available_payment_instruments(checkout, show_all_payment_instruments), "has_more_payment_instruments": !show_all_payment_instruments && available_payment_instrument_count(checkout) > PAYMENT_INSTRUMENT_LIMIT, - "next_steps": human_next_steps(actions), + "links": checkout_links(checkout), }) } @@ -177,33 +166,48 @@ fn selected_payment_instrument(checkout: &Value) -> Option<&Value> { }) } -fn selected_payment_id(checkout: &Value) -> Option<&str> { +pub(super) fn selected_payment_id(checkout: &Value) -> Option<&str> { selected_payment_instrument(checkout) .and_then(|instrument| instrument.get("id"))? .as_str() } -fn render_human(checkout: &Value) -> String { - if let Some(action) = checkout.get("action").and_then(Value::as_str) { - let body = checkout.get("body").cloned().unwrap_or(Value::Null); +fn checkout_links(checkout: &Value) -> Vec { + checkout + .get("links") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|link| { + let url = link.get("url").and_then(Value::as_str)?; + let link_type = link.get("type").and_then(Value::as_str).unwrap_or("link"); + let title = link + .get("title") + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| link_type.replace('_', " ")); + Some(json!({"title": title, "url": url, "type": link_type})) + }) + .collect() +} + +fn render_human(cart: &Value) -> String { + if let Some(action) = cart.get("action").and_then(Value::as_str) { + let body = cart.get("body").cloned().unwrap_or(Value::Null); return format!( "{action}\nRequest:\n{}\n", serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string()), ); } let mut output = format!( - "Checkout: {}\nStatus: {}\n", - checkout - .get("id") - .and_then(Value::as_str) - .unwrap_or_default(), - checkout - .get("status") + "Cart: {}\nStatus: {}\n", + cart.get("id").and_then(Value::as_str).unwrap_or_default(), + cart.get("status") .and_then(Value::as_str) .unwrap_or_default(), ); output.push_str("\nItems:\n"); - let items = checkout + let items = cart .get("items") .and_then(Value::as_array) .map(Vec::as_slice) @@ -232,22 +236,21 @@ fn render_human(checkout: &Value) -> String { } output.push_str(&format!( "\nSelected payment: {}\n", - checkout - .get("selected_payment") + cart.get("selected_payment") .and_then(Value::as_str) .unwrap_or("No payment method selected"), )); - render_available_payment_instruments(&mut output, checkout); - if let Some(total) = checkout.get("total").and_then(Value::as_str) { + render_available_payment_instruments(&mut output, cart); + if let Some(total) = cart.get("total").and_then(Value::as_str) { output.push_str(&format!("\nTotal: {total}\n")); } - output.push_str("\nCompletion places a real order. Review this checkout before continuing.\n"); - render_next_steps(&mut output, checkout); + render_links(&mut output, cart); + output.push_str("\nReview this cart and its links before placing an order.\n"); output } -fn render_available_payment_instruments(output: &mut String, checkout: &Value) { - let instruments = checkout +fn render_available_payment_instruments(output: &mut String, cart: &Value) { + let instruments = cart .get("available_payment_instruments") .and_then(Value::as_array) .map(Vec::as_slice) @@ -278,7 +281,7 @@ fn render_available_payment_instruments(output: &mut String, checkout: &Value) { .unwrap_or_default(), )); } - if checkout + if cart .get("has_more_payment_instruments") .and_then(Value::as_bool) .unwrap_or(false) @@ -287,25 +290,21 @@ fn render_available_payment_instruments(output: &mut String, checkout: &Value) { } } -pub(super) fn render_next_steps(output: &mut String, response: &Value) { - let steps = response - .get("next_steps") +fn render_links(output: &mut String, cart: &Value) { + let links = cart + .get("links") .and_then(Value::as_array) .map(Vec::as_slice) .unwrap_or_default(); - if steps.is_empty() { + if links.is_empty() { return; } - output.push_str("\nNext steps:\n"); - for step in steps { + output.push_str("\nImportant links:\n"); + for link in links { output.push_str(&format!( - " {}\n {}\n", - step.get("command") - .and_then(Value::as_str) - .unwrap_or_default(), - step.get("description") - .and_then(Value::as_str) - .unwrap_or_default(), + "- {}: {}\n", + link.get("title").and_then(Value::as_str).unwrap_or("Link"), + link.get("url").and_then(Value::as_str).unwrap_or_default(), )); } } @@ -327,12 +326,12 @@ mod tests { assert_eq!(default_instruments.len(), 5); assert_eq!(all_instruments.len(), 6); - let output = render_human(&human_response(&checkout, &[], false)); + let output = render_human(&human_response(&checkout, false)); assert!(output.contains("--show-all-payment-instruments")); } #[test] - fn human_view_masks_checkout_to_purchase_essentials() { + fn human_view_shows_cart_essentials_and_all_links() { let output = render_human(&human_response( &json!({ "id": "checkout-1", @@ -344,6 +343,10 @@ mod tests { }], "totals": [{"type": "total", "amount": 8388}], "currency": "USD", + "links": [ + {"type": "terms_of_service", "url": "https://example.test/terms"}, + {"type": "faq", "title": "Help centre", "url": "https://example.test/help"} + ], "payment": {"instruments": [{ "id": "payment-1", "selected": true, @@ -351,15 +354,16 @@ mod tests { "billing_address": {"street_address": "do not render"} }]} }), - &[], false, )); + assert!(output.contains("Cart: checkout-1")); assert!(output.contains("Web Hosting Economy")); assert!(output.contains("CREDIT_CARD/VISA 1111")); assert!(output.contains("Available payment methods:")); assert!(output.contains("Total: USD 83.88")); - assert!(output.contains("Completion places a real order")); + assert!(output.contains("terms of service: https://example.test/terms")); + assert!(output.contains("Help centre: https://example.test/help")); assert!(!output.contains("do not render")); } } diff --git a/rust/src/shopping/checkout/mod.rs b/rust/src/shopping/checkout/mod.rs index 662cd595..b5aef255 100644 --- a/rust/src/shopping/checkout/mod.rs +++ b/rust/src/shopping/checkout/mod.rs @@ -7,10 +7,9 @@ use cli_engine::{GroupSpec, RuntimeGroupSpec}; pub(super) fn group() -> RuntimeGroupSpec { RuntimeGroupSpec::new( - GroupSpec::new("checkout", "Create and manage Shopping checkout sessions").with_long( - "Create, update, and complete Shopping checkout sessions. Completion places a real \ - order. For a completed checkout, use `shopping order get` with the returned order ID; \ - `checkout get` is for open checkout sessions only.", + GroupSpec::new("checkout", "Create and manage carts").with_long( + "Create and update carts, then place an order after reviewing its payment methods and links. \ + Use `shopping order get` to review a completed purchase.", ), ) .with_command(create::command()) diff --git a/rust/src/shopping/checkout/update.rs b/rust/src/shopping/checkout/update.rs index 97ed145a..019a2463 100644 --- a/rust/src/shopping/checkout/update.rs +++ b/rust/src/shopping/checkout/update.rs @@ -57,11 +57,10 @@ struct Args { pub(super) fn command() -> RuntimeCommandSpec { RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::("update", "Optionally update a Shopping checkout") + CommandSpec::from_args::("update", "Optionally update a cart") .with_long( - "Optionally replace an open checkout before completion. Use --item and optional \ - buyer/payment flags for common changes, or --clear-items to deliberately empty the \ - cart. Updates replace checkout state; use --body or --file for advanced fields.", + "Optionally replace an open cart before placing an order. Use --item and buyer or \ + payment-method flags for common changes, or --clear-items to deliberately empty it.", ) .with_system("shopping") .with_tier(Tier::Mutate) @@ -108,15 +107,11 @@ pub(super) fn command() -> RuntimeCommandSpec { .await .map_err(client_err)?; let env = crate::environments::resolve(&ctx.middleware.env)?; - let actions = no_saved_payment_method_action( - &checkout, - &ctx.middleware.env, - &env.account_url, - ) + let actions = no_saved_payment_method_action(&checkout, &env.account_url) .into_iter() .collect::>(); let output = if ctx.middleware.output_format == "human" { - crate::shopping::checkout::get::human_response(&checkout, &actions, false) + crate::shopping::checkout::get::human_response(&checkout, false) } else { checkout }; diff --git a/rust/src/shopping/client.rs b/rust/src/shopping/client.rs index ae08f3ac..76811251 100644 --- a/rust/src/shopping/client.rs +++ b/rust/src/shopping/client.rs @@ -1,13 +1,8 @@ use std::time::Duration; -use reqwest::{Client, Method}; -use serde_json::{Value, json}; - -use crate::api_explorer::http::encode_path_segment; -use crate::application::client::make_http_client; - -const BASE_PATH: &str = "/v1/shopping"; +use serde_json::Value; +const USER_AGENT: &str = concat!("godaddy-cli/", env!("CARGO_PKG_VERSION")); #[derive(Debug, thiserror::Error)] pub enum ClientError { #[error("HTTP error {status}: {body}")] @@ -17,7 +12,13 @@ pub enum ClientError { retry_after: Option, }, #[error("network error: {0}")] - Network(#[from] reqwest::Error), + Network(String), + #[error("request error: {0}")] + Request(String), + #[error("failed to decode Shopping API response: {0}")] + Response(#[from] serde_json::Error), + #[error("failed to construct Shopping API client: {0}")] + Build(#[from] shopping_client::BuildError), } impl ClientError { @@ -35,7 +36,7 @@ impl ClientError { pub fn retry_after(&self) -> Option { match self { Self::Http { retry_after, .. } => *retry_after, - Self::Network(_) => None, + Self::Network(_) | Self::Request(_) | Self::Response(_) | Self::Build(_) => None, } } } @@ -44,132 +45,155 @@ impl From for crate::error::GddyError { fn from(value: ClientError) -> Self { match value { ClientError::Http { status, body, .. } => Self::from_http(status, body, "shopping"), - ClientError::Network(error) => { - Self::network(format!("network error: {error}")).with_system("shopping") + ClientError::Network(error) | ClientError::Request(error) => { + Self::network(error).with_system("shopping") + } + ClientError::Response(error) => { + Self::unexpected(format!("failed to decode Shopping API response: {error}")) + .with_system("shopping") + } + ClientError::Build(error) => { + Self::config(format!("failed to construct Shopping API client: {error}")) + .with_system("shopping") } } } } pub struct ShoppingClient { - client: Client, base_url: String, - token: String, + authorization: String, } impl ShoppingClient { - pub fn new(base_url: impl Into, token: impl Into) -> Self { - Self { - client: make_http_client(), - base_url: base_url.into(), - token: token.into(), - } - } - - fn url(&self, path: &str) -> String { - format!("{}{BASE_PATH}{path}", self.base_url) + pub fn new(base_url: impl AsRef, token: impl AsRef) -> Result { + let client = Self { + base_url: base_url.as_ref().to_owned(), + authorization: format!("Bearer {}", token.as_ref()), + }; + client.client()?; + Ok(client) } - async fn send_json( - &self, - method: Method, - path: &str, - body: Option, - ) -> Result { - let mut request = self - .client - .request(method, self.url(path)) - .bearer_auth(&self.token) - .header("x-request-id", uuid::Uuid::new_v4().to_string()); - if let Some(body) = body { - request = request.json(&body); - } - let request = request.build()?; - let response = self.client.execute(request).await?; - let status = response.status(); - let headers = response.headers().clone(); - let retry_after = headers - .get(reqwest::header::RETRY_AFTER) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()) - .map(Duration::from_secs); - let bytes = response.bytes().await?; - - let status = status.as_u16(); - if status == 204 || bytes.is_empty() { - return if (200..300).contains(&status) { - Ok(json!(null)) - } else { - Err(ClientError::Http { - status, - body: String::new(), - retry_after, - }) - }; - } - if !(200..300).contains(&status) { - return Err(ClientError::Http { - status, - body: String::from_utf8_lossy(&bytes).into_owned(), - retry_after, - }); - } - serde_json::from_slice(&bytes).map_err(|error| ClientError::Http { - status, - body: format!( - "invalid JSON response: {error} (body: {})", - String::from_utf8_lossy(&bytes) - ), - retry_after: None, - }) + fn client(&self) -> Result { + shopping_client::client_with_auth( + &self.base_url, + &self.authorization, + USER_AGENT, + &uuid::Uuid::new_v4().to_string(), + ) + .map_err(ClientError::Build) } pub async fn catalog_search(&self, body: Value) -> Result { - self.send_json(Method::POST, "/catalog/search", Some(body)) - .await + let body: shopping_client::types::SearchRequest = deserialize(body)?; + response(self.client()?.search_catalog().body(body).send().await).await } pub async fn catalog_lookup(&self, body: Value) -> Result { - self.send_json(Method::POST, "/catalog/lookup", Some(body)) - .await + let body: shopping_client::types::LookupRequest = deserialize(body)?; + response(self.client()?.lookup_catalog().body(body).send().await).await } pub async fn catalog_product(&self, body: Value) -> Result { - self.send_json(Method::POST, "/catalog/product", Some(body)) - .await + let body: shopping_client::types::GetProductRequest = deserialize(body)?; + response(self.client()?.get_product().body(body).send().await).await } pub async fn create_checkout(&self, body: Value) -> Result { - self.send_json(Method::POST, "/checkout-sessions", Some(body)) - .await + let body: shopping_client::types::CheckoutWritableRequest = deserialize(body)?; + response(self.client()?.create_checkout().body(body).send().await).await } pub async fn get_checkout(&self, id: &str) -> Result { - self.send_json(Method::GET, &checkout_path(id, ""), None) - .await + response(self.client()?.get_checkout().id(id).send().await).await } pub async fn update_checkout(&self, id: &str, body: Value) -> Result { - self.send_json(Method::PUT, &checkout_path(id, ""), Some(body)) - .await + let body: shopping_client::types::CheckoutWritableRequest = deserialize(body)?; + response( + self.client()? + .update_checkout() + .id(id) + .body(body) + .send() + .await, + ) + .await } - pub async fn complete_checkout(&self, id: &str, body: Value) -> Result { - self.send_json(Method::POST, &checkout_path(id, "/complete"), Some(body)) - .await + pub async fn complete_checkout( + &self, + id: &str, + body: Value, + idempotency_key: &str, + ) -> Result { + let body: shopping_client::types::CheckoutCompleteRequest = deserialize(body)?; + response( + self.client()? + .complete_checkout() + .id(id) + .idempotency_key(idempotency_key) + .body(body) + .send() + .await, + ) + .await } pub async fn get_order(&self, id: &str) -> Result { - self.send_json(Method::GET, &order_path(id), None).await + response(self.client()?.get_order().id(id).send().await).await } } -fn checkout_path(id: &str, suffix: &str) -> String { - format!("/checkout-sessions/{}{suffix}", encode_path_segment(id)) +fn deserialize(body: Value) -> Result { + serde_json::from_value(body).map_err(ClientError::Response) } -fn order_path(id: &str) -> String { - format!("/orders/{}", encode_path_segment(id)) +async fn response( + response: Result, progenitor_client::Error<()>>, +) -> Result { + match response { + Ok(response) => serde_json::to_value(response.into_inner()).map_err(ClientError::Response), + Err(progenitor_client::Error::InvalidResponsePayload(bytes, _)) if bytes.is_empty() => { + Ok(Value::Null) + } + Err(progenitor_client::Error::InvalidResponsePayload(bytes, _)) => { + // UCP extension metadata can evolve independently of the core + // response schemas. Preserve a successful JSON response for the + // CLI's dynamic projection when typed decoding cannot represent it. + serde_json::from_slice(&bytes).map_err(ClientError::Response) + } + Err(progenitor_client::Error::UnexpectedResponse(response)) + if response.status().is_success() => + { + let bytes = response.bytes().await.unwrap_or_default(); + if bytes.is_empty() { + Ok(Value::Null) + } else { + serde_json::from_slice(&bytes).map_err(ClientError::Response) + } + } + Err(progenitor_client::Error::UnexpectedResponse(response)) => { + let status = response.status().as_u16(); + let retry_after = response + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .map(Duration::from_secs); + let body = response.text().await.unwrap_or_default(); + Err(ClientError::Http { + status, + body, + retry_after, + }) + } + Err(progenitor_client::Error::CommunicationError(error)) => { + Err(ClientError::Network(error.to_string())) + } + Err(error) => Err(ClientError::Request(error.to_string())), + } } #[cfg(test)] @@ -181,7 +205,7 @@ mod tests { use super::*; fn client(base_url: &str) -> ShoppingClient { - ShoppingClient::new(base_url, "test-token") + ShoppingClient::new(base_url, "test-token").expect("client should build") } fn assert_http_error( @@ -201,29 +225,10 @@ mod tests { assert_eq!(retry_after, expected_retry_after); Ok(()) } - ClientError::Network(error) => Err(format!( - "expected HTTP error, received network error: {error}" - )), + error => Err(format!("expected HTTP error, received {error}")), } } - #[test] - fn builds_shopping_paths_from_the_api_front_door() { - assert_eq!( - client("https://api.test-godaddy.com").url("/catalog/search"), - "https://api.test-godaddy.com/v1/shopping/catalog/search" - ); - } - - #[test] - fn encodes_dynamic_ids_as_path_segments() { - assert_eq!( - checkout_path("session/a?b#c%d", "/complete"), - "/checkout-sessions/session%2Fa%3Fb%23c%25d/complete" - ); - assert_eq!(order_path("order/a?b#c%d"), "/orders/order%2Fa%3Fb%23c%25d"); - } - #[tokio::test] async fn catalog_operations_send_expected_requests() { let server = MockServer::start_async().await; @@ -237,7 +242,7 @@ mod tests { .header("authorization", "Bearer test-token") .header_exists("x-request-id") .json_body(search_request.clone()); - then.status(200).json_body(json!({"operation": "search"})); + then.status(200).json_body(json!({"products": []})); }) .await; let lookup = server @@ -247,7 +252,7 @@ mod tests { .header("authorization", "Bearer test-token") .header_exists("x-request-id") .json_body(lookup_request.clone()); - then.status(200).json_body(json!({"operation": "lookup"})); + then.status(200).json_body(json!({"products": []})); }) .await; let product = server @@ -257,32 +262,23 @@ mod tests { .header("authorization", "Bearer test-token") .header_exists("x-request-id") .json_body(product_request.clone()); - then.status(200).json_body(json!({"operation": "product"})); + then.status(200).json_body(json!({"products": []})); }) .await; let shopping = client(&server.base_url()); - assert_eq!( - shopping - .catalog_search(search_request) - .await - .expect("search")["operation"], - "search" - ); - assert_eq!( - shopping - .catalog_lookup(lookup_request) - .await - .expect("lookup")["operation"], - "lookup" - ); - assert_eq!( - shopping - .catalog_product(product_request) - .await - .expect("product")["operation"], - "product" - ); + shopping + .catalog_search(search_request) + .await + .expect("search"); + shopping + .catalog_lookup(lookup_request) + .await + .expect("lookup"); + shopping + .catalog_product(product_request) + .await + .expect("product"); search.assert_async().await; lookup.assert_async().await; @@ -290,7 +286,7 @@ mod tests { } #[tokio::test] - async fn checkout_lifecycle_uses_expected_methods_paths_and_bodies() { + async fn checkout_lifecycle_uses_expected_methods_paths_bodies_and_headers() { let server = MockServer::start_async().await; let checkout_request = json!({ "line_items": [{"item": {"id": "variant-1"}, "quantity": 1}], @@ -303,10 +299,8 @@ mod tests { when.method(POST) .path("/v1/shopping/checkout-sessions") .header("authorization", "Bearer test-token") - .header_exists("x-request-id") - .json_body(checkout_request.clone()); - then.status(201) - .json_body(json!({"id": "checkout-123", "operation": "create"})); + .header_exists("x-request-id"); + then.status(201).json_body(json!({"id": "checkout-123"})); }) .await; let get = server @@ -315,8 +309,7 @@ mod tests { .path("/v1/shopping/checkout-sessions/checkout-123") .header("authorization", "Bearer test-token") .header_exists("x-request-id"); - then.status(200) - .json_body(json!({"id": "checkout-123", "operation": "get"})); + then.status(200).json_body(json!({"id": "checkout-123"})); }) .await; let update = server @@ -324,10 +317,8 @@ mod tests { when.method(PUT) .path("/v1/shopping/checkout-sessions/checkout-123") .header("authorization", "Bearer test-token") - .header_exists("x-request-id") - .json_body(checkout_request.clone()); - then.status(200) - .json_body(json!({"id": "checkout-123", "operation": "update"})); + .header_exists("x-request-id"); + then.status(200).json_body(json!({"id": "checkout-123"})); }) .await; let complete = server @@ -336,38 +327,25 @@ mod tests { .path("/v1/shopping/checkout-sessions/checkout-123/complete") .header("authorization", "Bearer test-token") .header_exists("x-request-id") - .json_body(completion_request.clone()); - then.status(200) - .json_body(json!({"id": "checkout-123", "operation": "complete"})); + .header("idempotency-key", "customer-key"); + then.status(200).json_body(json!({"id": "checkout-123"})); }) .await; let shopping = client(&server.base_url()); - assert_eq!( - shopping - .create_checkout(checkout_request.clone()) - .await - .expect("create")["operation"], - "create" - ); - assert_eq!( - shopping.get_checkout("checkout-123").await.expect("get")["operation"], - "get" - ); - assert_eq!( - shopping - .update_checkout("checkout-123", checkout_request) - .await - .expect("update")["operation"], - "update" - ); - assert_eq!( - shopping - .complete_checkout("checkout-123", completion_request) - .await - .expect("complete")["operation"], - "complete" - ); + shopping + .create_checkout(checkout_request.clone()) + .await + .expect("create"); + shopping.get_checkout("checkout-123").await.expect("get"); + shopping + .update_checkout("checkout-123", checkout_request) + .await + .expect("update"); + shopping + .complete_checkout("checkout-123", completion_request, "customer-key") + .await + .expect("complete"); create.assert_async().await; get.assert_async().await; @@ -409,7 +387,8 @@ mod tests { let complete = server .mock_async(|when, then| { when.method(POST) - .path("/v1/shopping/checkout-sessions/checkout-123/complete"); + .path("/v1/shopping/checkout-sessions/checkout-123/complete") + .header("idempotency-key", "customer-key"); then.status(204); }) .await; @@ -424,7 +403,7 @@ mod tests { ); assert_eq!( shopping - .complete_checkout("checkout-123", json!({})) + .complete_checkout("checkout-123", json!({}), "customer-key") .await .expect("empty completion"), Value::Null @@ -435,24 +414,35 @@ mod tests { } #[tokio::test] - async fn encodes_dynamic_checkout_ids_on_the_wire() { + async fn encodes_dynamic_ids_on_the_wire() { let server = MockServer::start_async().await; - let mock = server + let checkout = server + .mock_async(|when, then| { + when.method(GET) + .path("/v1/shopping/checkout-sessions/session%2Fa%3Fb%23c%25d"); + then.status(200).json_body(json!({"id": "checkout"})); + }) + .await; + let order = server .mock_async(|when, then| { when.method(GET) - .path("/v1/shopping/checkout-sessions/session%2Fa%3Fb%23c%25d") - .header("authorization", "Bearer test-token"); - then.status(200).json_body(json!({"id": "encoded"})); + .path("/v1/shopping/orders/order%2Fa%3Fb%23c%25d"); + then.status(200).json_body(json!({"id": "order"})); }) .await; - let checkout = client(&server.base_url()) + let shopping = client(&server.base_url()); + shopping .get_checkout("session/a?b#c%d") .await - .expect("get encoded checkout"); + .expect("encoded checkout"); + shopping + .get_order("order/a?b#c%d") + .await + .expect("encoded order"); - mock.assert_async().await; - assert_eq!(checkout["id"], "encoded"); + checkout.assert_async().await; + order.assert_async().await; } #[tokio::test] @@ -490,7 +480,7 @@ mod tests { .mock_async(|when, then| { when.method(GET).path("/v1/shopping/orders/123"); then.status(404) - .json_body(json!({ "error": "order_not_found" })); + .json_body(json!({"error": "order_not_found"})); }) .await; @@ -549,7 +539,7 @@ mod tests { } #[tokio::test] - async fn reports_malformed_success_json() -> TestResult<(), String> { + async fn reports_malformed_success_json() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { @@ -565,22 +555,8 @@ mod tests { mock.assert_async().await; match error { - ClientError::Http { - status, - body, - retry_after, - } => { - assert_eq!(status, 200); - assert!(body.contains("invalid JSON response")); - assert!(body.contains("not-json")); - assert_eq!(retry_after, None); - } - ClientError::Network(error) => { - return Err(format!( - "expected HTTP error, received network error: {error}" - )); - } + ClientError::Response(error) => assert!(error.to_string().contains("expected ident")), + error => panic!("expected decode error, received {error}"), } - Ok(()) } } diff --git a/rust/src/shopping/common.rs b/rust/src/shopping/common.rs index 2e76139b..0e6f9524 100644 --- a/rust/src/shopping/common.rs +++ b/rust/src/shopping/common.rs @@ -15,7 +15,7 @@ pub(crate) async fn make_client(ctx: &CommandContext) -> Result .collect(); let token = ctx.credential_with_scopes(&required).await?.token; let base_url = crate::environments::resolve(&ctx.middleware.env)?.api_url; - Ok(ShoppingClient::new(base_url, token)) + ShoppingClient::new(base_url, token).map_err(client_err) } pub(crate) fn client_err(error: ClientError) -> CliCoreError { @@ -110,7 +110,7 @@ impl CheckoutInput { self.body(true) } - pub(crate) fn completion_body(&self, idempotency_key: Option<&str>) -> Result { + pub(crate) fn completion_body(&self) -> Result { if !self.items.is_empty() || self.clear_items || self.currency.is_some() @@ -120,7 +120,7 @@ impl CheckoutInput { || self.buyer_phone.is_some() { return Err(GddyError::validation( - "checkout complete only supports --payment-instrument and --idempotency-key in structured mode", + "checkout complete only supports --payment-instrument in structured mode", ) .into_cli_error()); } @@ -128,15 +128,9 @@ impl CheckoutInput { self.payment_instrument.as_deref(), "--payment-instrument must be non-empty", )?; - let mut body = json!({ + Ok(json!({ "payment": {"instruments": [{"id": payment_instrument, "selected": true}]} - }); - if let Some(idempotency_key) = idempotency_key { - body.as_object_mut() - .expect("completion body is an object") - .insert("idempotency_key".to_owned(), json!(idempotency_key)); - } - Ok(body) + })) } fn body(&self, allow_empty_items: bool) -> Result { @@ -314,7 +308,6 @@ pub(crate) fn reject_mixed_checkout_input( pub(crate) fn no_saved_payment_method_action( checkout: &Value, - env: &str, account_url: &str, ) -> Option { checkout @@ -322,13 +315,8 @@ pub(crate) fn no_saved_payment_method_action( .and_then(Value::as_array) .is_some_and(Vec::is_empty) .then(|| { - let command = if matches!(env, "prod" | "production") { - "payment-methods add".to_owned() - } else { - format!("--env {env} payment-methods add") - }; next_action( - command, + "payment-methods add", format!( "No saved payment method is available. Add one at {account_url}/payment-methods/add-payment, then retrieve this checkout again." ), @@ -336,26 +324,6 @@ pub(crate) fn no_saved_payment_method_action( }) } -/// Returns a supplied non-empty key, or inserts a new UUID for this one request. -pub(crate) fn ensure_completion_idempotency_key(body: &mut Value) -> Result { - let object = body - .as_object_mut() - .expect("read_json validates the completion request is an object"); - match object.get("idempotency_key") { - None => { - let key = uuid::Uuid::new_v4().to_string(); - object.insert("idempotency_key".to_owned(), Value::String(key.clone())); - Ok(key) - } - Some(Value::String(key)) if !key.trim().is_empty() => Ok(key.clone()), - Some(_) => Err(GddyError::validation( - "idempotency_key must be a non-empty string when supplied", - ) - .with_fix("Supply a non-empty idempotency_key, or omit it to let gddy generate one.") - .into_cli_error()), - } -} - pub(crate) async fn wait_for_order( client: &ShoppingClient, order_id: &str, @@ -445,32 +413,6 @@ mod tests { use super::*; use crate::shopping::command_for_env; - #[test] - fn generates_and_inserts_missing_completion_idempotency_key() { - let mut request = json!({}); - let key = ensure_completion_idempotency_key(&mut request).expect("key should be generated"); - - assert!(uuid::Uuid::parse_str(&key).is_ok()); - assert_eq!(request["idempotency_key"], key); - } - - #[test] - fn preserves_supplied_completion_idempotency_key() { - let mut request = json!({"idempotency_key": "customer-key"}); - - assert_eq!( - ensure_completion_idempotency_key(&mut request).expect("key should be valid"), - "customer-key" - ); - } - - #[test] - fn rejects_blank_completion_idempotency_key() { - let mut request = json!({"idempotency_key": " "}); - - assert!(ensure_completion_idempotency_key(&mut request).is_err()); - } - #[test] fn builds_structured_checkout_body() { let body = CheckoutInput { @@ -554,14 +496,13 @@ mod tests { payment_instrument: Some("payment-1".to_owned()), ..CheckoutInput::default() } - .completion_body(Some("customer-key")) + .completion_body() .expect("completion body should build"); assert_eq!( body, json!({ - "payment": {"instruments": [{"id": "payment-1", "selected": true}]}, - "idempotency_key": "customer-key" + "payment": {"instruments": [{"id": "payment-1", "selected": true}]} }) ); } @@ -576,26 +517,14 @@ mod tests { #[test] fn adds_payment_method_actions_for_resolved_environment_urls() { let empty_instruments = json!({"payment": {"instruments": []}}); - for (env, account_url, command) in [ - ( - "prod", - "https://account.godaddy.com", - "gddy payment-methods add", - ), - ( - "test", - "https://account.test-godaddy.com", - "gddy --env test payment-methods add", - ), - ( - "dev", - "https://account.dev-godaddy.com", - "gddy --env dev payment-methods add", - ), + for account_url in [ + "https://account.godaddy.com", + "https://account.test-godaddy.com", + "https://account.dev-godaddy.com", ] { - let action = no_saved_payment_method_action(&empty_instruments, env, account_url) + let action = no_saved_payment_method_action(&empty_instruments, account_url) .expect("empty list should require a payment method"); - assert_eq!(action.command, command); + assert_eq!(action.command, "gddy payment-methods add"); assert!( action .description @@ -603,12 +532,8 @@ mod tests { ); } assert!( - no_saved_payment_method_action( - &json!({"payment": {}}), - "prod", - "https://account.godaddy.com", - ) - .is_none() + no_saved_payment_method_action(&json!({"payment": {}}), "https://account.godaddy.com") + .is_none() ); } @@ -638,10 +563,10 @@ mod tests { } #[test] - fn preserves_named_environment_in_order_wait_recovery_command() { + fn follow_up_commands_rely_on_the_selected_environment() { assert_eq!( command_for_env("test", "order get order-1 --wait"), - "--env test shopping order get order-1 --wait" + "shopping order get order-1 --wait" ); } diff --git a/rust/src/shopping/guides/shopping.md b/rust/src/shopping/guides/shopping.md index 2325e37f..d79b8e2c 100644 --- a/rust/src/shopping/guides/shopping.md +++ b/rust/src/shopping/guides/shopping.md @@ -1,266 +1,100 @@ --- -summary: Browse products, place orders, and retrieve purchase details. +summary: Find GoDaddy products, add them to a cart, place an order, and review purchases. --- -# Shopping API +# Shopping for GoDaddy Products -`gddy shopping` integrates with the Shopping API. +Use `gddy shopping` to find GoDaddy products, add selected product variants to a cart, place an order, and review completed purchases. -## Authentication +## Concepts -Every Shopping command requests these OAuth scopes together, so the first command -can take the customer through one consent flow for the complete lifecycle: +- A **product** can have multiple purchasable **variants**. Choose a variant that has the features and term you want. +- A **cart** holds the variants you intend to purchase. The `shopping checkout` commands manage this cart. +- A cart can show eligible saved **payment methods**. Their availability can depend on the cart, including its currency. +- A cart includes important links, such as terms, privacy, refund, shipping, or help information. Review every listed link before placing an order. +- An **order** is created when the cart is completed. -```text -shopping.catalog:read -shopping.checkout:execute -shopping.order:read -``` +## Find a product -You can authenticate before running a workflow: +Search all available products or use a text query: ```bash -gddy auth login \ - --scope shopping.catalog:read \ - --scope shopping.checkout:execute \ - --scope shopping.order:read +gddy shopping catalog search +gddy shopping catalog search --query hosting --currency GBP --limit 3 ``` -Shopping requests use the selected environment's standard API front door. OAuth requests the -complete lifecycle scope bundle above in one consent flow. PAT support requires those Shopping -scopes to be available on the Developer Portal and is tracked separately. - -## Environment - -Commands below use the active environment. To run them against another configured -environment, add `--env ` before `shopping`: - -```bash -gddy --env shopping catalog search -``` - -## Request documents and output - -Use `--body` for a small inline JSON request or `--file` for a reusable JSON document. -`--file` takes precedence over `--body`. For common checkout workflows, use the checkout -flags below; use JSON for advanced nested API fields. Do not combine checkout flags with -`--body` or `--file` in the same command. - -JSON is the default output format. For successful non-dry-run requests, the full -Shopping API response is in the envelope's `data` field. Add `--output human` for a -concise terminal presentation, or `--output json` to make the default explicit. Use -`gddy shopping --help` for command-specific requirements. - -## Discover products - -Search the catalog without a request body. Use `--query`, repeatable `--category`, `--cursor`, -`--limit`, `--currency`, and `--country` to refine a search: - -```bash -gddy shopping catalog search \ - --query email \ - --category email \ - --currency GBP \ - --country GB \ - --limit 3 -``` - -`--currency` writes `context.currency` into the request. The API response price currency -is authoritative; a requested currency is a preference, not a guarantee. The current -Shopping service applies the preference to catalog search; catalog lookup and product -retrieval currently accept the context but can return their default USD prices. Use -`--body` or `--file` for advanced filters and extensions. A raw `filters.price` request -must include `context.currency`, because its bounds are currency-specific minor units. - -`catalog search` displays products as numbered sections. Each section shows its product -ID, then purchasable variants and prices. `--limit` controls **products**, not variants. -Use a **product ID** with `catalog get` or `catalog lookup`; use a **variant ID** in -`checkout create` line items. A variant is already term-specific—select the variant whose -returned **Term** column matches the desired term. +Use `--category` to narrow the results. It can be repeated when more than one category applies: ```bash -gddy shopping catalog lookup \ - --id nes-wsb-vnext-tier1 \ - --currency GBP - -gddy shopping catalog get \ - --id nes-wsb-vnext-tier1 \ - --currency GBP +gddy shopping catalog search --category webHosting --category email ``` -To receive the full catalog response as valid JSON: +`--limit` controls the number of products, not the number of variants. Use a product ID with `catalog get` or `catalog lookup`; use a variant ID when creating a cart. A requested currency is a preference—the currency returned with the price is authoritative. ```bash -gddy --output json shopping catalog search --limit 3 +gddy shopping catalog get --id --currency GBP +gddy shopping catalog lookup --id --currency GBP ``` -Use the opaque response cursor with the same search criteria: +Use `--output json` when you need the complete command output: ```bash -gddy shopping catalog search \ - --query email \ - --currency GBP \ - --limit 3 \ - --cursor '' +gddy --output json shopping catalog search --query hosting ``` -For advanced filters or extensions not represented by command flags, continue using a JSON -request through `--body` or `--file`. - -## Create a checkout ready to complete +## Create and review a cart -Creating a checkout does not place an order. A create response includes the checkout ID, -priced line items, total, and up to five available payment instruments with IDs and -descriptions, so a separate `checkout get` is not required before completion when the -checkout is already ready. Add `--show-all-payment-instruments` to show every available -method. Include buyer, payment, and other supported checkout information when creating a -checkout that is ready to complete. +Create a cart with one selected variant. Add buyer details when they are needed for the product or payment method: ```bash gddy shopping checkout create \ - --item '' \ - --currency USD \ + --item \ + --currency GBP \ --buyer-first-name Jane \ --buyer-last-name Doe \ - --buyer-email jane.doe@example.test \ - --buyer-phone '+15550100' + --buyer-email jane.doe@example.com ``` -Repeat `--item` to create a cart; append `=QUANTITY` to an item, such as -`--item '=2'`. Add `--payment-instrument ` -to select one stored payment method. It is optional at creation: a ready checkout can expose a -saved instrument for selection at completion. - -A stored payment instrument normally supplies its saved billing address automatically. Use a -JSON document when you need an address override or other advanced nested fields: +Repeat `--item` to add variants. Append `=QUANTITY` when you need more than one of a variant: -```json -{ - "line_items": [ - { - "item": {"id": ""}, - "quantity": 1, - "input": { - "type": "", - "references": {"": ""} - } - } - ], - "buyer": { - "first_name": "Jane", - "last_name": "Doe", - "email": "jane.doe@example.test", - "phone_number": "+15550100" - }, - "context": { - "currency": "USD" - }, - "payment": { - "instruments": [{ - "id": "", - "selected": true, - "billing_address": { - "street_address": "123 Example Street", - "extended_address": "Suite 200", - "address_locality": "Exampleville", - "address_region": "CA", - "postal_code": "94043", - "address_country": "US", - "first_name": "Jane", - "last_name": "Doe", - "phone_number": "+15550100" - } - }] - } -} +```bash +gddy shopping checkout create --item =2 ``` -`line_items` is the only required top-level field for create or update. Each line item requires -an `item.id` (a catalog variant ID) and a positive integer `quantity`. Include `input` only when -the selected variant's published input schema requires it. `buyer`, `context`, `signals`, -`attribution`, `payment`, and `fulfillment` are optional. Do not send response-owned fields such -as checkout `id`, `status`, `totals`, `currency`, `messages`, `order`, or `ucp`. - -Only one payment instrument may be specified for checkout create, update, or complete. Use -`--file checkout.json` rather than placing address information in shell history. +The response shows cart items, its selected and available payment methods, the final total when available, and all important links. It lists five payment methods by default; add `--show-all-payment-instruments` to show every available method. -Use `checkout get ` when you need to inspect an existing open checkout or -recover its available payment instruments. Human output shows checkout status, items, the -selected payment method, and up to five available saved payment methods with their IDs and -descriptions. Add `--show-all-payment-instruments` to show every available method. Use -`--output json` for the full response. - -## Optionally update an open checkout +```bash +gddy shopping checkout get --show-all-payment-instruments +``` -`checkout update` is optional. Use it only to change an existing checkout. It replaces the -checkout state, so structured updates must include every desired cart item. Use `--clear-items` -only to deliberately empty the cart. +To select an eligible saved payment method explicitly, provide its ID when creating or updating the cart. Only one payment method can be selected. ```bash gddy shopping checkout update \ - --item '=2' \ - --buyer-email jane.doe@example.test + --item \ + --payment-instrument ``` -Use `--file update-checkout.json` for advanced replacement fields, such as fulfillment, product -input, attribution, signals, or a billing-address override. +Updating a cart is optional. It replaces the cart's writable contents, so include every item you want to keep. Use `--clear-items` only when you intend to empty the cart. -## Complete a checkout +## Place an order -`checkout complete` places a real order. Select exactly one saved payment instrument. The -common form is: +Review the cart, its payment method, and every important link first. Then place the order with `--agree` to acknowledge the links: ```bash gddy shopping checkout complete \ - --payment-instrument + --payment-instrument \ + --agree ``` -Use `--idempotency-key ` to supply a non-empty key, or omit it to let -gddy generate one and return it in human output. Preserve the effective key for lost-response -recovery. - -Use `--file complete-checkout.json` for an optional billing-address override or another -advanced payment field. The JSON may specify only one payment instrument: - -```json -{ - "payment": { - "instruments": [ - { - "id": "", - "selected": true, - "billing_address": { - "street_address": "123 Example Street", - "address_locality": "Exampleville", - "address_region": "CA", - "postal_code": "94043", - "address_country": "US" - } - } - ] - }, - "idempotency_key": "" -} -``` +If a payment method is already selected on the cart, `--payment-instrument` can be omitted. -Completion returns immediately after the single purchase attempt. A successful response includes -the order ID and a **View order** permalink for the customer's account. It never automatically -retries. If the result is uncertain, first confirm the outcome; only then reuse the same -effective idempotency key for the same intended purchase. +## Review an order -New orders normally become available 3–10 seconds after completion. Retrieve the order -separately, optionally polling for up to 15 seconds by default: +A newly placed order can take a few seconds to become available. Use `--wait` to check until it appears: ```bash gddy shopping order get --wait --wait-timeout 15 ``` -The engine-wide `--timeout` remains independent of order visibility waiting: - -```bash -gddy --timeout 30s shopping order get \ - --wait --wait-timeout 15 -``` - -After completion, use `shopping order get` with the returned order ID. `shopping checkout get` -is for open checkout sessions only. +Use `shopping order get` for completed purchases. Do not use `shopping checkout get` after an order is placed. diff --git a/rust/src/shopping/mod.rs b/rust/src/shopping/mod.rs index 9fa95428..fb53de56 100644 --- a/rust/src/shopping/mod.rs +++ b/rust/src/shopping/mod.rs @@ -17,21 +17,14 @@ use crate::shopping::order::get::register_human_view as register_order_get_human use crate::scopes::{SHOPPING_CATALOG_READ, SHOPPING_CHECKOUT_EXECUTE, SHOPPING_ORDER_READ}; -/// Every Shopping operation requests the full lifecycle scope bundle at once. -/// This deliberately avoids disruptive OAuth consent/step-up during the common -/// catalog → checkout → order workflow. pub(crate) const SHOPPING_SCOPES: &[&str] = &[ SHOPPING_CATALOG_READ, SHOPPING_CHECKOUT_EXECUTE, SHOPPING_ORDER_READ, ]; -pub(crate) fn command_for_env(env: &str, command: impl AsRef) -> String { - if matches!(env, "prod" | "production") { - format!("shopping {}", command.as_ref()) - } else { - format!("--env {env} shopping {}", command.as_ref()) - } +pub(crate) fn command_for_env(_env: &str, command: impl AsRef) -> String { + format!("shopping {}", command.as_ref()) } pub fn module() -> Module { @@ -45,15 +38,11 @@ pub fn module() -> Module { RuntimeGroupSpec::new( GroupSpec::new( "shopping", - "Browse GoDaddy products, execute checkout, and view completed orders", + "Explore GoDaddy products, place orders, and review purchases", ) .with_long( - "Browse GoDaddy products, create/update/complete checkout, and view completed orders.\n\ - \n\ - Shopping commands request the required OAuth permissions together so you can \ - complete the catalog → checkout → order workflow without additional consent prompts.\n\ - \n\ - Use `gddy guide shopping` for request formats and checkout-completion safety.", + "Find GoDaddy products, add them to a cart, place an order, and review your purchases. \ + Use `gddy guide shopping` for a step-by-step purchase flow.", ), ) .with_group(catalog::group()) diff --git a/rust/src/shopping/money.rs b/rust/src/shopping/money.rs index bb799d03..346f3878 100644 --- a/rust/src/shopping/money.rs +++ b/rust/src/shopping/money.rs @@ -1,7 +1,9 @@ use serde_json::Value; -/// Shopping amounts are ISO-4217 minor units. Their decimal scale derives from the returned -/// currency code, not a fixed cents assumption. +use crate::domain::common::format_minor_units; + +/// Shopping amounts are ISO-4217 minor units; formatting delegates to the +/// shared Domains currency implementation. pub(crate) fn format_value(value: Option<&Value>) -> Option { let amount = value?.get("amount")?.as_i64()?; let currency = value?.get("currency")?.as_str()?; @@ -20,42 +22,7 @@ pub(crate) fn format_total(totals: Option<&Value>, currency: Option<&str>) -> Op } pub(crate) fn format_amount(amount: i64, currency: &str) -> String { - let sign = if amount < 0 { "-" } else { "" }; - let absolute = amount.unsigned_abs(); - let decimals = currency_decimals(currency); - let scale = 10u64.pow(decimals); - let whole = grouped_integer(absolute / scale); - if decimals == 0 { - format!("{currency} {sign}{whole}") - } else { - format!( - "{currency} {sign}{whole}.{:0width$}", - absolute % scale, - width = decimals as usize - ) - } -} - -fn currency_decimals(currency: &str) -> u32 { - iso_currency::Currency::from_code(¤cy.to_ascii_uppercase()) - .and_then(|currency| currency.exponent()) - .map_or(2, u32::from) -} - -fn grouped_integer(value: u64) -> String { - let digits = value.to_string(); - let first_group = digits.len() % 3; - let mut output = String::with_capacity(digits.len() + digits.len() / 3); - if first_group > 0 { - output.push_str(&digits[..first_group]); - } - for index in (first_group..digits.len()).step_by(3) { - if !output.is_empty() { - output.push(','); - } - output.push_str(&digits[index..index + 3]); - } - output + format_minor_units(amount, currency, true, true) } #[cfg(test)] @@ -90,18 +57,5 @@ mod tests { ), None ); - assert_eq!( - format_total( - Some(&serde_json::json!([{"type": "subtotal", "amount": 7188}])), - Some("USD") - ), - None - ); - } - - #[test] - fn formats_negative_and_unknown_currency_amounts() { - assert_eq!(format_amount(-500, "USD"), "USD -5.00"); - assert_eq!(format_amount(1_234, "ZZZ"), "ZZZ 12.34"); } } diff --git a/rust/tools/generate-api-catalog/src/dereference.rs b/rust/tools/generate-api-catalog/src/dereference.rs index a29b7a1e..493f320e 100644 --- a/rust/tools/generate-api-catalog/src/dereference.rs +++ b/rust/tools/generate-api-catalog/src/dereference.rs @@ -75,22 +75,22 @@ fn derive_defs_key_for_path(ref_str: &str) -> String { }; let base_key = sanitize_defs_key(stem); - // A `#/properties/` fragment (optionally nested, e.g. `#/properties/a/properties/b`) - // selects one property's schema out of the file, not the file's root schema — fold the - // property path into the key so two refs into the same file for different properties - // don't collide under one bare file-stem key. Each segment has its literal `_` doubled - // before joining on a single `_`, so a property literally named `a_b` can't collide with - // nested segments `a` + `b` (both would otherwise sanitize to the same `a_b` suffix). - match frag.and_then(|f| f.strip_prefix("#/properties/")) { - Some(rest) => { - let suffix = rest - .split("/properties/") - .map(|seg| seg.replace('_', "__")) - .collect::>() - .join("_"); - sanitize_defs_key(&format!("{base_key}_{suffix}")) - } - None => base_key, + // A fragment selecting one schema from a multi-schema document needs its + // own key. Without it, every UCP ref through `ucp-refs.schema.json` would + // collide under one definition and silently produce incorrect generated types. + match frag.and_then(|f| f.strip_prefix("#/$defs/")) { + Some(name) => sanitize_defs_key(&format!("{base_key}_{name}")), + None => match frag.and_then(|f| f.strip_prefix("#/properties/")) { + Some(rest) => { + let suffix = rest + .split("/properties/") + .map(|seg| seg.replace('_', "__")) + .collect::>() + .join("_"); + sanitize_defs_key(&format!("{base_key}_{suffix}")) + } + None => base_key, + }, } } @@ -182,6 +182,10 @@ fn resolve_ref( return resolve_local_ref(root, ref_str).cloned(); } + if ref_str == "https://json-schema.org/draft/2020-12/schema" { + return Some(serde_json::json!(true)); + } + if ref_str.starts_with("https://schemas.api.godaddy.com/") { let ct_dir = common_types_dir?; let url_path = ref_str @@ -197,6 +201,22 @@ fn resolve_ref( return load_external_ref(&local_path, common_types_dir, defs, depth); } + if let Some(url_path) = ref_str.strip_prefix("https://godaddy.com/ucp/schemas/") { + let (file_part, fragment) = split_external_ref(url_path); + let local_path = specification_root(spec_dir)? + .join("schemas") + .join(file_part); + return resolve_external_fragment(&local_path, fragment, common_types_dir, defs, depth); + } + + if let Some(url_path) = ref_str.strip_prefix("https://ucp.dev/schemas/shopping/") { + let (file_part, fragment) = split_external_ref(url_path); + let local_path = specification_root(spec_dir)? + .join("schemas/ucp/shopping") + .join(file_part); + return resolve_external_fragment(&local_path, fragment, common_types_dir, defs, depth); + } + // Relative file reference — strip fragment let (file_part, fragment) = match ref_str.find('#') { Some(i) => (&ref_str[..i], Some(&ref_str[i..])), @@ -227,6 +247,37 @@ fn resolve_ref( Some(external_root) } +fn split_external_ref(reference: &str) -> (&str, Option<&str>) { + match reference.find('#') { + Some(index) => (&reference[..index], Some(&reference[index..])), + None => (reference, None), + } +} + +fn resolve_external_fragment( + path: &Path, + fragment: Option<&str>, + common_types_dir: Option<&Path>, + defs: &mut IndexMap, + depth: usize, +) -> Option { + let root = load_external_ref(path, common_types_dir, defs, depth)?; + fragment.map_or(Some(root.clone()), |fragment| { + resolve_local_ref(&root, fragment).cloned() + }) +} + +fn specification_root(spec_dir: &Path) -> Option<&Path> { + let mut current = Some(spec_dir); + while let Some(path) = current { + if path.join("schemas").is_dir() { + return Some(path); + } + current = path.parent(); + } + None +} + fn load_external_ref( path: &Path, common_types_dir: Option<&Path>, diff --git a/rust/tools/generate-api-catalog/src/github.rs b/rust/tools/generate-api-catalog/src/github.rs index 70c48c8c..f2713fd3 100644 --- a/rust/tools/generate-api-catalog/src/github.rs +++ b/rust/tools/generate-api-catalog/src/github.rs @@ -34,6 +34,7 @@ pub(crate) struct SpecSource { pub(crate) spec_file: PathBuf, pub(crate) spec_version: String, pub(crate) graphql_only: bool, + pub(crate) catalog: bool, } fn github_client() -> Result { @@ -138,9 +139,11 @@ fn find_latest_spec_file(repo_dir: &Path) -> Option<(String, PathBuf, bool)> { for (_, version) in candidates.iter().rev() { for name in &["openapi.yaml", "openapi.yml", "openapi.json"] { - let p = repo_dir.join(version).join("schemas").join(name); - if p.exists() { - return Some((version.clone(), p, false)); + for relative_path in [Path::new("schemas").join(name), PathBuf::from(name)] { + let p = repo_dir.join(version).join(relative_path); + if p.exists() { + return Some((version.clone(), p, false)); + } } } for name in &["graphql/schema.graphql", "schema.graphql"] { @@ -333,6 +336,7 @@ pub(crate) fn discover_spec_sources( spec_file, spec_version: version, graphql_only, + catalog: source.catalog, }); } @@ -367,6 +371,7 @@ pub(crate) fn local_spec_sources(manifest: &CatalogSourceManifest) -> Result Result<()> { ) .context("failed to refresh domains-client codegen spec")?; } + if let Some(shopping_source) = sources.iter().find(|s| s.domain == "shopping") { + shopping_merge::refresh( + &shopping_source.spec_file, + common_types, + &shopping_merge::shopping_client_oas3_path(), + ) + .context("failed to refresh shopping-client codegen spec")?; + } sources.extend(local_spec_sources(&source_manifest)?); @@ -121,7 +130,7 @@ fn main() -> Result<()> { let mut active_files: HashSet = HashSet::new(); let mut total_endpoints = 0usize; - for source in &sources { + for source in sources.iter().filter(|source| source.catalog) { eprintln!( "Processing {} ({}/{})...", source.domain, source.repo_name, source.spec_version diff --git a/rust/tools/generate-api-catalog/src/manifest.rs b/rust/tools/generate-api-catalog/src/manifest.rs index a449b8ed..3afb60c5 100644 --- a/rust/tools/generate-api-catalog/src/manifest.rs +++ b/rust/tools/generate-api-catalog/src/manifest.rs @@ -13,6 +13,12 @@ const SOURCE_MANIFEST_JSON: &str = include_str!("../../../api-catalog-sources.js pub(crate) struct RemoteCatalogSource { pub(crate) domain: String, pub(crate) repository: String, + #[serde(default = "default_catalog")] + pub(crate) catalog: bool, +} + +const fn default_catalog() -> bool { + true } #[derive(Debug, Deserialize)] @@ -34,6 +40,7 @@ impl CatalogSourceManifest { let mut domains: Vec = self .remote .iter() + .filter(|source| source.catalog) .map(|source| source.domain.clone()) .chain(self.local.iter().map(|source| source.domain.clone())) .collect(); @@ -71,7 +78,15 @@ mod tests { let expected = manifest.expected_domains(); assert_eq!(expected.len(), 22); - assert_eq!(manifest.remote.len(), 21); + assert_eq!(manifest.remote.len(), 22); + assert_eq!( + manifest + .remote + .iter() + .filter(|source| !source.catalog) + .count(), + 1 + ); assert_eq!( manifest .local diff --git a/rust/tools/generate-api-catalog/src/shopping_merge.rs b/rust/tools/generate-api-catalog/src/shopping_merge.rs new file mode 100644 index 00000000..4f448a8e --- /dev/null +++ b/rust/tools/generate-api-catalog/src/shopping_merge.rs @@ -0,0 +1,342 @@ +//! Refreshes the vendored Shopping OpenAPI document used by `shopping-client`. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde_json::{Map, Value}; + +const HOST: &str = "https://api.godaddy.com"; + +pub(crate) fn shopping_client_oas3_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../shopping-client/openapi/shopping.oas3.json") +} + +/// Fully dereferences the approved Shopping contract and applies the small +/// compatibility normalization needed by Progenitor 0.14, which consumes OAS +/// 3.0 documents. The source contract is OAS 3.1 but does not use 3.1-only +/// schema constructs in the generated API surface. +pub(crate) fn refresh( + spec_path: &Path, + common_types: Option<&Path>, + out_path: &Path, +) -> Result<()> { + let (mut spec, defs) = crate::dereference::load_and_dereference(spec_path, common_types) + .with_context(|| { + format!( + "failed to dereference Shopping spec {}", + spec_path.display() + ) + })?; + let object = spec + .as_object_mut() + .context("Shopping spec is not a JSON object")?; + let schemas = object + .entry("components") + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .context("Shopping spec components is not an object")? + .entry("schemas") + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .context("Shopping spec components.schemas is not an object")?; + for (name, definition) in defs { + schemas.insert(name, definition); + } + rewrite_defs_refs(&mut spec); + remove_remaining_relative_refs(&mut spec); + remove_self_referencing_schemas(&mut spec); + + let object = spec + .as_object_mut() + .context("Shopping spec is not a JSON object")?; + object.insert("openapi".to_owned(), Value::String("3.0.3".to_owned())); + object.insert( + "servers".to_owned(), + serde_json::json!([{ "url": HOST, "description": "Shopping API host" }]), + ); + prefix_paths(&mut spec)?; + remove_protocol_header_parameters(&mut spec); + retain_2xx_responses(&mut spec); + relax_response_schemas(&mut spec); + preserve_dynamic_ucp_response_metadata(&mut spec)?; + preserve_payment_instrument_id(&mut spec)?; + add_completion_idempotency_key(&mut spec)?; + + let json = serde_json::to_string_pretty(&spec).context("serialize Shopping codegen spec")?; + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + std::fs::write(out_path, json).with_context(|| format!("write {}", out_path.display()))?; + eprintln!(" wrote {}", out_path.display()); + Ok(()) +} + +fn rewrite_defs_refs(value: &mut Value) { + match value { + Value::Object(map) => { + if let Some(Value::String(reference)) = map.get("$ref") + && let Some(name) = reference.strip_prefix("#/$defs/") + { + map.insert( + "$ref".to_owned(), + Value::String(format!("#/components/schemas/{name}")), + ); + } + for child in map.values_mut() { + rewrite_defs_refs(child); + } + } + Value::Array(values) => { + for value in values { + rewrite_defs_refs(value); + } + } + _ => {} + } +} + +fn remove_remaining_relative_refs(value: &mut Value) { + match value { + Value::Object(map) => { + if map + .get("$ref") + .and_then(Value::as_str) + .is_some_and(|reference| !reference.starts_with('#') && !reference.contains("://")) + { + map.clear(); + return; + } + for child in map.values_mut() { + remove_remaining_relative_refs(child); + } + } + Value::Array(values) => { + for value in values { + remove_remaining_relative_refs(value); + } + } + _ => {} + } +} + +fn remove_self_referencing_schemas(spec: &mut Value) { + let Some(schemas) = spec + .pointer_mut("/components/schemas") + .and_then(Value::as_object_mut) + else { + return; + }; + let names = schemas.keys().cloned().collect::>(); + for name in names { + let reference = format!("#/components/schemas/{name}"); + if schemas + .get(&name) + .and_then(|schema| schema.get("$ref")) + .and_then(Value::as_str) + == Some(reference.as_str()) + { + schemas.insert(name, serde_json::json!({})); + } + } +} + +fn prefix_paths(spec: &mut Value) -> Result<()> { + let paths = spec + .as_object_mut() + .context("Shopping spec is not an object")? + .remove("paths") + .and_then(|paths| paths.as_object().cloned()) + .context("Shopping spec has no paths")?; + let paths = paths + .into_iter() + .filter(|(path, _)| path.starts_with('/')) + .map(|(path, item)| (format!("/v1/shopping{path}"), item)) + .collect(); + spec.as_object_mut() + .expect("Shopping spec is an object") + .insert("paths".to_owned(), Value::Object(paths)); + Ok(()) +} + +fn remove_protocol_header_parameters(spec: &mut Value) { + let Some(paths) = spec.pointer_mut("/paths").and_then(Value::as_object_mut) else { + return; + }; + for item in paths.values_mut().filter_map(Value::as_object_mut) { + for method in ["get", "post", "put", "delete", "patch"] { + let Some(parameters) = item + .get_mut(method) + .and_then(|operation| operation.get_mut("parameters")) + .and_then(Value::as_array_mut) + else { + continue; + }; + parameters.retain(|parameter| { + !matches!( + parameter.get("name").and_then(Value::as_str), + Some("UCP-Agent") | Some("Request-Id") | Some("Idempotency-Key") + ) + }); + } + } +} + +fn preserve_dynamic_ucp_response_metadata(spec: &mut Value) -> Result<()> { + let schemas = spec + .get_mut("components") + .and_then(|components| components.get_mut("schemas")) + .and_then(Value::as_object_mut) + .context("Shopping component schemas are missing")?; + for name in [ + "ucp_response_catalog_schema", + "ucp_response_checkout_schema", + "ucp_response_order_schema", + "ucp_success", + ] { + let properties = schemas + .get_mut(name) + .and_then(|schema| schema.get_mut("allOf")) + .and_then(Value::as_array_mut) + .and_then(|schemas| schemas.get_mut(0)) + .and_then(|schema| schema.get_mut("properties")) + .and_then(Value::as_object_mut) + .with_context(|| format!("Shopping {name} properties are missing"))?; + // Deployed UCP responses use capabilities as an array while the + // published schema describes a keyed object. Keep metadata dynamic so + // the typed client preserves the response for CLI projection. + properties.insert("capabilities".to_owned(), serde_json::json!({})); + } + Ok(()) +} + +fn preserve_payment_instrument_id(spec: &mut Value) -> Result<()> { + let properties = spec + .get_mut("components") + .and_then(|components| components.get_mut("schemas")) + .and_then(|schemas| schemas.get_mut("payment_instrument_selected_payment_instrument")) + .and_then(|schema| schema.get_mut("allOf")) + .and_then(Value::as_array_mut) + .and_then(|schemas| schemas.get_mut(1)) + .and_then(|schema| schema.get_mut("properties")) + .and_then(Value::as_object_mut) + .context("Shopping selected payment instrument properties are missing")?; + // The contract's dynamic instrument reference cannot be represented by + // Progenitor. Keep the selected instrument ID required by deployed APIs. + properties.insert("id".to_owned(), serde_json::json!({"type": "string"})); + Ok(()) +} + +fn add_completion_idempotency_key(spec: &mut Value) -> Result<()> { + let object = spec + .as_object_mut() + .context("Shopping spec is not an object")?; + let paths = object + .get_mut("paths") + .and_then(Value::as_object_mut) + .context("Shopping spec has no paths")?; + let operation = paths + .get_mut("/v1/shopping/checkout-sessions/{id}/complete") + .and_then(|path| path.get_mut("post")) + .and_then(Value::as_object_mut) + .context("Shopping completion operation is missing")?; + let parameters = operation + .entry("parameters") + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .context("Shopping completion parameters are not an array")?; + if !parameters + .iter() + .any(|parameter| parameter.get("name").and_then(Value::as_str) == Some("Idempotency-Key")) + { + // The API contract already defines this transport header, but current + // backend deployments still require the matching body field below. + // Retain the header so clients are compatible when that gap is closed. + parameters.push(serde_json::json!({ + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": {"type": "string"} + })); + } + + let properties = object + .get_mut("components") + .and_then(|components| components.get_mut("schemas")) + .and_then(|schemas| schemas.get_mut("checkout-complete-request_schema")) + .and_then(|schema| schema.get_mut("properties")) + .and_then(Value::as_object_mut) + .context("Shopping completion request properties are missing")?; + // Temporary compatibility shim: the current backend accepts the key only + // in the completion body. Remove this field when it accepts the standard + // Idempotency-Key header without a duplicate body value. + properties.insert( + "idempotency_key".to_owned(), + serde_json::json!({"type": "string"}), + ); + Ok(()) +} + +fn retain_2xx_responses(spec: &mut Value) { + let Some(paths) = spec.pointer_mut("/paths").and_then(Value::as_object_mut) else { + return; + }; + for item in paths.values_mut().filter_map(Value::as_object_mut) { + for method in ["get", "post", "put", "delete", "patch"] { + let Some(responses) = item + .get_mut(method) + .and_then(|operation| operation.get_mut("responses")) + .and_then(Value::as_object_mut) + else { + continue; + }; + responses.retain(|status, _| status.starts_with('2')); + } + } +} + +fn relax_response_schemas(spec: &mut Value) { + let Some(schemas) = spec + .pointer_mut("/components/schemas") + .and_then(Value::as_object_mut) + else { + return; + }; + for schema in schemas.values_mut() { + relax(schema); + } + if matches!(schemas.get("schema"), Some(Value::Bool(_))) { + schemas.insert("schema".to_owned(), serde_json::json!({})); + } +} + +fn relax(value: &mut Value) { + match value { + Value::Object(map) => { + map.remove("required"); + map.remove("format"); + if map.get("$ref").and_then(Value::as_str) == Some("#") { + map.clear(); + } + map.remove("pattern"); + if let Some(Value::Array(types)) = map.get("type") { + if let Some(Value::String(value_type)) = + types.iter().find(|value| value.is_string()) + { + map.insert("type".to_owned(), Value::String(value_type.clone())); + } else { + map.remove("type"); + } + } + for child in map.values_mut() { + relax(child); + } + } + Value::Array(values) => { + for value in values { + relax(value); + } + } + _ => {} + } +}