From 281b0b8193746bad06054c719bfc242b7e00935f Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Tue, 14 Jul 2026 18:45:21 -0700 Subject: [PATCH 1/2] fix(domain): comma-join repeatable TLD flags before sending as query params (DEVEX-882) progenitor's generated client always seq-serializes a Vec query-param setter as repeated key=value pairs, ignoring the OpenAPI spec's `style: form, explode: false` for `tlds`. Multiple --tlds/--tld occurrences on `domain suggest` and `domain agreements` were sent as tlds=com&tlds=net&tlds=io instead of tlds=com,net,io, which the API rejects. Co-Authored-By: Claude Sonnet 5 --- rust/src/domain/agreements.rs | 47 +++++++++++++++++++++++++++++++++-- rust/src/domain/common.rs | 41 ++++++++++++++++++++++++++++++ rust/src/domain/suggest.rs | 41 ++++++++++++++++++++++++++++-- 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/rust/src/domain/agreements.rs b/rust/src/domain/agreements.rs index 1019296..b4392f1 100644 --- a/rust/src/domain/agreements.rs +++ b/rust/src/domain/agreements.rs @@ -7,7 +7,7 @@ use serde_json::json; use domains_client::types; -use super::common::{api_error, make_client, string_list}; +use super::common::{api_error, comma_joined, make_client, string_list}; use crate::next_action::next_action; use crate::scopes::DOMAINS_READ; @@ -55,7 +55,13 @@ pub(super) fn command() -> RuntimeCommandSpec { .unwrap_or(false); let debug = !ctx.middleware.debug.is_empty(); let client = make_client(&ctx).await?; - let resp = match client.agreements().tlds(tlds).privacy(privacy).send().await { + let resp = match client + .agreements() + .tlds(comma_joined(tlds)) + .privacy(privacy) + .send() + .await + { Ok(r) => r, Err(e) => return Err(api_error("retrieving legal agreements", debug, e).await), }; @@ -88,3 +94,40 @@ pub(super) fn command() -> RuntimeCommandSpec { }, ) } + +#[cfg(test)] +mod tests { + use super::super::common::comma_joined; + + #[tokio::test] + async fn tlds_are_sent_as_a_single_comma_joined_query_param() { + // Regression for DEVEX-882: the v1 `tlds` query param is OpenAPI + // `style: form, explode: false` — one comma-joined value. progenitor's + // generated `tlds()` setter always seq-serializes a `Vec` as repeated + // `tlds=` pairs, so callers must join multiple `--tld` occurrences into + // a single element before calling it, or the API rejects the request. + let server = httpmock::MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(httpmock::Method::GET) + .path("/v1/domains/agreements") + .query_param("tlds", "com,net,io"); + then.status(200).json_body(serde_json::json!([])); + }) + .await; + let client = + domains_client::client_with_auth(&server.base_url(), "Bearer tok", "test", "req-1") + .expect("build client"); + + let tlds = comma_joined(vec!["com".to_string(), "net".to_string(), "io".to_string()]); + client + .agreements() + .tlds(tlds) + .privacy(false) + .send() + .await + .expect("request succeeds"); + + mock.assert_async().await; + } +} diff --git a/rust/src/domain/common.rs b/rust/src/domain/common.rs index 372ee4c..8153858 100644 --- a/rust/src/domain/common.rs +++ b/rust/src/domain/common.rs @@ -136,6 +136,23 @@ pub(crate) fn string_list(ctx: &CommandContext, key: &str) -> Vec { } } +/// Collapse a repeatable CLI flag's values into the single query-string value +/// some domains-client endpoints require (e.g. `tlds`, whose OpenAPI param is +/// `style: form, explode: false` — one comma-joined value). progenitor's +/// generated setters always seq-serialize a `Vec` argument as repeated +/// `key=value` pairs regardless of the spec's `explode` setting, so passing +/// multiple `--tlds` occurrences straight through sends `tlds=com&tlds=net` +/// and the API rejects it (`400 MISMATCH_FORMAT`, DEVEX-882). Joining into a +/// single element before calling the setter produces the one pair the API +/// expects. `[]` stays `[]` so callers can still gate on "no filter given". +pub(crate) fn comma_joined(values: Vec) -> Vec { + if values.is_empty() { + Vec::new() + } else { + vec![values.join(",")] + } +} + /// Turn a domains-client error into a `CliCoreError`, reading the response body /// for unexpected (non-2xx) responses so the API's actual message isn't lost /// (progenitor's `Display` prints only the status). Async because reading the @@ -329,6 +346,30 @@ fn split_camel_case(s: &str) -> String { mod tests { use super::*; + #[test] + fn comma_joined_collapses_multiple_values_into_one_element() { + // Regression for DEVEX-882: repeated `--tlds`/`--tld` values must become + // one comma-joined query element, not stay as separate elements (which + // progenitor would send as repeated `tlds=` pairs). + assert_eq!( + comma_joined(vec!["com".to_string(), "net".to_string(), "io".to_string()]), + vec!["com,net,io".to_string()] + ); + } + + #[test] + fn comma_joined_single_value_is_unchanged() { + assert_eq!( + comma_joined(vec!["com".to_string()]), + vec!["com".to_string()] + ); + } + + #[test] + fn comma_joined_empty_stays_empty() { + assert_eq!(comma_joined(Vec::::new()), Vec::::new()); + } + fn money(value: Option, currency: &str) -> types::SimpleMoney { types::SimpleMoney { value, diff --git a/rust/src/domain/suggest.rs b/rust/src/domain/suggest.rs index ca15e85..e285a07 100644 --- a/rust/src/domain/suggest.rs +++ b/rust/src/domain/suggest.rs @@ -5,7 +5,9 @@ use serde_json::json; use domains_client::types; -use super::common::{api_error, format_money, make_client, string_list, term_for_period}; +use super::common::{ + api_error, comma_joined, format_money, make_client, string_list, term_for_period, +}; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; @@ -137,7 +139,7 @@ pub(super) fn command() -> RuntimeCommandSpec { req = req.page_size(page_size); } if !tlds.is_empty() { - req = req.tlds(tlds); + req = req.tlds(comma_joined(tlds)); } if let Some(n) = length_min.and_then(nonzero) { req = req.length_min(n); @@ -163,6 +165,7 @@ pub(super) fn command() -> RuntimeCommandSpec { #[cfg(test)] mod tests { + use super::super::common::comma_joined; use super::{nonzero, suggestion_to_json}; use domains_client::types; @@ -175,6 +178,40 @@ mod tests { assert_eq!(nonzero(5).map(|n| n.get()), Some(5)); } + #[tokio::test] + async fn tlds_are_sent_as_a_single_comma_joined_query_param() { + // Regression for DEVEX-882: the `tlds` query param is OpenAPI `style: + // form, explode: false` — one comma-joined value. progenitor's generated + // `tlds()` setter always seq-serializes a `Vec` as repeated `tlds=` + // pairs, so callers must join multiple `--tlds` occurrences into a + // single element before calling it, or the API replies `400 + // MISMATCH_FORMAT`. + let server = httpmock::MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(httpmock::Method::GET) + .path("/v3/domains/suggestions") + .query_param("tlds", "com,net,io"); + then.status(200) + .json_body(serde_json::json!({ "items": [] })); + }) + .await; + let client = + domains_client::client_with_auth(&server.base_url(), "Bearer tok", "test", "req-1") + .expect("build client"); + + let tlds = comma_joined(vec!["com".to_string(), "net".to_string(), "io".to_string()]); + client + .suggest_domains() + .query("pizza") + .tlds(tlds) + .send() + .await + .expect("request succeeds"); + + mock.assert_async().await; + } + fn money(value: i64, currency: &str) -> types::SimpleMoney { types::SimpleMoney { value: Some(value), From a0927333aa019faa253ab2364a06c4a4d3448645 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Tue, 14 Jul 2026 18:48:43 -0700 Subject: [PATCH 2/2] fix(domain): avoid an unnecessary alloc in comma_joined for single values Address Copilot review feedback on #106: values.join(",") always allocates a new String even for the single-element case. Return the Vec unchanged when there's nothing to join. Co-Authored-By: Claude Sonnet 5 --- rust/src/domain/common.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/src/domain/common.rs b/rust/src/domain/common.rs index 8153858..58a405b 100644 --- a/rust/src/domain/common.rs +++ b/rust/src/domain/common.rs @@ -146,8 +146,8 @@ pub(crate) fn string_list(ctx: &CommandContext, key: &str) -> Vec { /// single element before calling the setter produces the one pair the API /// expects. `[]` stays `[]` so callers can still gate on "no filter given". pub(crate) fn comma_joined(values: Vec) -> Vec { - if values.is_empty() { - Vec::new() + if values.len() <= 1 { + values } else { vec![values.join(",")] }