fix(schema): expose repeated field and expand array query params (#415)

* 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<String, String> to
Vec<(String, String)> to support multiple entries with the same key.

Closes #300

* 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.

* 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.

* 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.
This commit is contained in:
Anshul Garg
2026-03-14 00:43:59 +05:30
committed by GitHub
parent 659c66b5c2
commit 21b18407b9
3 changed files with 111 additions and 20 deletions
@@ -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
+87 -20
View File
@@ -65,7 +65,7 @@ struct ExecutionInput {
params: Map<String, Value>,
body: Option<Value>,
full_url: String,
query_params: HashMap<String, String>,
query_params: Vec<(String, String)>,
is_upload: bool,
}
@@ -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 {
@@ -484,7 +484,7 @@ fn build_url(
method: &RestMethod,
params: &Map<String, Value>,
is_upload: bool,
) -> Result<(String, HashMap<String, String>), 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<String, String> = 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,39 @@ 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;
}
}
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(),
};
query_params.push((key.clone(), val_str));
}
let url_path = render_path_template(path_template, params)?;
@@ -966,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]
@@ -1269,7 +1297,46 @@ mod tests {
let (url, query) = build_url(&doc, &method, &params, 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, &params, 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 +1942,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 +1982,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 +2020,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,
};
+19
View File
@@ -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(&param);
assert_eq!(json["type"], "string");
assert_eq!(json["repeated"], true);
}
#[test]