From 117d2187bd64ca4e9415b99071a7807d7aeedbf7 Mon Sep 17 00:00:00 2001 From: Anshul Garg Date: Wed, 11 Mar 2026 22:54:16 +0530 Subject: [PATCH 1/4] fix(schema): expose repeated field and expand array query params Two related fixes for repeated/array query parameters: 1. `gws schema` now includes `"repeated": true` in parameter output when the Discovery Document marks a parameter as repeated. This lets users know which params accept multiple values. 2. When `--params` contains a JSON array for a parameter marked `repeated: true`, the executor now expands it into multiple query parameters (e.g. `?h=Subject&h=Date&h=From`) instead of stringifying the array as a single value. The query_params type changes from HashMap to Vec<(String, String)> to support multiple entries with the same key. Closes #300 --- .changeset/schema-repeated-array-params.md | 5 ++ src/executor.rs | 85 ++++++++++++++++++---- src/schema.rs | 19 +++++ 3 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 .changeset/schema-repeated-array-params.md diff --git a/.changeset/schema-repeated-array-params.md b/.changeset/schema-repeated-array-params.md new file mode 100644 index 00000000..6e280425 --- /dev/null +++ b/.changeset/schema-repeated-array-params.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Expose `repeated: true` in `gws schema` output and expand JSON arrays into repeated query parameters for `repeated` fields diff --git a/src/executor.rs b/src/executor.rs index 49101ece..2f208a1b 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -65,7 +65,7 @@ struct ExecutionInput { params: Map, body: Option, full_url: String, - query_params: HashMap, + query_params: Vec<(String, String)>, is_upload: bool, } @@ -484,7 +484,7 @@ fn build_url( method: &RestMethod, params: &Map, is_upload: bool, -) -> Result<(String, HashMap), GwsError> { +) -> Result<(String, Vec<(String, String)>), GwsError> { // Build URL base and path // Actually we need to construct base URL properly if not present @@ -521,14 +521,9 @@ fn build_url( // Substitute path parameters and separate query parameters let path_parameters = extract_template_path_parameters(path_template); - let mut query_params: HashMap = HashMap::new(); + let mut query_params: Vec<(String, String)> = Vec::new(); for (key, value) in params { - let val_str = match value { - Value::String(s) => s.clone(), - other => other.to_string(), - }; - if path_parameters.contains(key.as_str()) { continue; } @@ -546,8 +541,31 @@ fn build_url( ))); } - // It's a query parameter - query_params.insert(key.clone(), val_str); + // For repeated parameters, expand JSON arrays into multiple query entries + let is_repeated = method + .parameters + .get(key) + .map(|p| p.repeated) + .unwrap_or(false); + + if is_repeated { + if let Value::Array(arr) = value { + for item in arr { + let val_str = match item { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + query_params.push((key.clone(), val_str)); + } + continue; + } + } + + let val_str = match value { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + query_params.push((key.clone(), val_str)); } let url_path = render_path_template(path_template, params)?; @@ -1269,7 +1287,46 @@ mod tests { let (url, query) = build_url(&doc, &method, ¶ms, false).unwrap(); assert_eq!(url, "https://api.example.com/files"); - assert_eq!(query.get("q").unwrap(), "search term"); + assert_eq!(query, vec![("q".to_string(), "search term".to_string())]); + } + + #[test] + fn test_build_url_repeated_query_param_expands_array() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut method_params = HashMap::new(); + method_params.insert( + "metadataHeaders".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + location: Some("query".to_string()), + repeated: true, + ..Default::default() + }, + ); + let method = RestMethod { + path: "messages".to_string(), + flat_path: Some("messages".to_string()), + parameters: method_params, + ..Default::default() + }; + let mut params = Map::new(); + params.insert( + "metadataHeaders".to_string(), + json!(["Subject", "Date", "From"]), + ); + + let (_url, query) = build_url(&doc, &method, ¶ms, false).unwrap(); + assert_eq!( + query, + vec![ + ("metadataHeaders".to_string(), "Subject".to_string()), + ("metadataHeaders".to_string(), "Date".to_string()), + ("metadataHeaders".to_string(), "From".to_string()), + ] + ); } #[test] @@ -1875,7 +1932,7 @@ async fn test_post_without_body_sets_content_length_zero() { full_url: "https://example.com/messages/trash".to_string(), body: None, params: Map::new(), - query_params: HashMap::new(), + query_params: Vec::new(), is_upload: false, }; @@ -1915,7 +1972,7 @@ async fn test_post_with_body_does_not_add_content_length_zero() { full_url: "https://example.com/files".to_string(), body: Some(json!({"name": "test"})), params: Map::new(), - query_params: HashMap::new(), + query_params: Vec::new(), is_upload: false, }; @@ -1953,7 +2010,7 @@ async fn test_get_does_not_set_content_length_zero() { full_url: "https://example.com/files".to_string(), body: None, params: Map::new(), - query_params: HashMap::new(), + query_params: Vec::new(), is_upload: false, }; diff --git a/src/schema.rs b/src/schema.rs index 9de6731b..f6f54821 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -210,6 +210,9 @@ fn param_to_json(param: &MethodParameter) -> Value { if let Some(ref vals) = param.enum_values { p["enum"] = json!(vals); } + if param.repeated { + p["repeated"] = json!(true); + } if param.deprecated { p["deprecated"] = json!(true); } @@ -345,6 +348,22 @@ mod tests { assert_eq!(json["default"], "0"); assert!(json["enum"].is_array()); assert_eq!(json["deprecated"], true); + // repeated: false should NOT appear in output + assert!(json.get("repeated").is_none()); + } + + #[test] + fn test_param_to_json_repeated() { + let param = MethodParameter { + param_type: Some("string".to_string()), + location: Some("query".to_string()), + repeated: true, + ..Default::default() + }; + + let json = param_to_json(¶m); + assert_eq!(json["type"], "string"); + assert_eq!(json["repeated"], true); } #[test] From 4df98143e2c4257205f12eaebaa6f4c077564f2d Mon Sep 17 00:00:00 2001 From: Anshul Garg Date: Wed, 11 Mar 2026 23:07:58 +0530 Subject: [PATCH 2/4] fix: warn when array is passed for a non-repeated query parameter Address review feedback: print a warning to stderr when a JSON array is provided for a parameter not marked as repeated, since the array will be stringified rather than expanded. Directs users to gws schema to check which parameters accept arrays. --- src/executor.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/executor.rs b/src/executor.rs index 2f208a1b..b5cd67b1 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -561,6 +561,14 @@ fn build_url( } } + if !is_repeated && value.is_array() { + eprintln!( + "Warning: parameter '{}' is not marked as repeated; array value will be stringified. \ + Use `gws schema` to check which parameters accept arrays.", + key + ); + } + let val_str = match value { Value::String(s) => s.clone(), other => other.to_string(), From 663a5710b48acd52378f63f1dcaee6c709e85349 Mon Sep 17 00:00:00 2001 From: Anshul Garg Date: Fri, 13 Mar 2026 01:59:51 +0530 Subject: [PATCH 3/4] fix: import MethodParameter in executor test module The test `test_build_url_repeated_query_param_expands_array` uses MethodParameter but it was not imported in the test module's use statement, causing a compilation error in CI. --- src/executor.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/executor.rs b/src/executor.rs index b5cd67b1..a2108650 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -992,7 +992,9 @@ pub fn mime_to_extension(mime: &str) -> &str { #[cfg(test)] mod tests { use super::*; - use crate::discovery::{JsonSchema, JsonSchemaProperty, RestDescription, RestMethod}; + use crate::discovery::{ + JsonSchema, JsonSchemaProperty, MethodParameter, RestDescription, RestMethod, + }; use serde_json::json; #[test] From fe787ddfb16466fd650438c82e091c43457486ae Mon Sep 17 00:00:00 2001 From: Anshul Garg Date: Fri, 13 Mar 2026 02:14:56 +0530 Subject: [PATCH 4/4] fix: pass all query params in a single request.query() call Consolidate query parameters (including pageToken) into a single request.query() call to ensure repeated parameters are sent correctly instead of being overwritten by successive calls. --- src/executor.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/executor.rs b/src/executor.rs index a2108650..327904d6 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -170,12 +170,12 @@ async fn build_http_request( request = request.header("x-goog-user-project", quota_project); } - for (key, value) in &input.query_params { - request = request.query(&[(key, value)]); - } - + let mut all_query_params = input.query_params.clone(); if let Some(pt) = page_token { - request = request.query(&[("pageToken", pt)]); + all_query_params.push(("pageToken".to_string(), pt.to_string())); + } + if !all_query_params.is_empty() { + request = request.query(&all_query_params); } if pages_fetched == 0 {