From 3b511af95a51cac73297044a6e30997649ef73d5 Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Tue, 8 Sep 2026 11:10:00 +0200 Subject: [PATCH 1/2] [rust] fix: exploded deepObject free-form query parameters do not compile The exploded deepObject branch walks every map-flagged parameter with .len()/.iter() - fine for a HashMap-typed map (an object schema with declared additionalProperties), but a bare free-form object is a serde_json::Value, which has neither, so the generated crate did not compile. Split the branch on isContainer and walk the Value through as_object(): a non-object Value sends nothing, matching the parameter's declared object shape. The second of the two follow-ups promised in #24866's body. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GcwZ1arjLZNpetHz2a3TJz --- .../resources/rust/reqwest-trait/api.mustache | 24 ++++++++++++++++++ .../main/resources/rust/reqwest/api.mustache | 24 ++++++++++++++++++ .../codegen/rust/RustClientCodegenTest.java | 25 +++++++++++++++++++ .../deep-object-free-form-query-param.yaml | 25 +++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 modules/openapi-generator/src/test/resources/3_0/rust/deep-object-free-form-query-param.yaml diff --git a/modules/openapi-generator/src/main/resources/rust/reqwest-trait/api.mustache b/modules/openapi-generator/src/main/resources/rust/reqwest-trait/api.mustache index cf16c0fc24b2..2a593c656587 100644 --- a/modules/openapi-generator/src/main/resources/rust/reqwest-trait/api.mustache +++ b/modules/openapi-generator/src/main/resources/rust/reqwest-trait/api.mustache @@ -206,11 +206,23 @@ impl {{classname}} for {{classname}}Client { {{/isModel}} {{#isMap}} if let Some(ref param_value) = {{{paramName}}} { + {{#isContainer}} let mut query_params = Vec::with_capacity(param_value.len()); for (key, value) in param_value.iter() { query_params.push((key.to_string(), serde_json::to_string(value)?)); } local_var_req_builder = local_var_req_builder.query(&query_params); + {{/isContainer}} + {{^isContainer}} + // a free-form object is a serde_json::Value, only walkable through as_object() + if let Some(object) = param_value.as_object() { + let mut query_params = Vec::with_capacity(object.len()); + for (key, value) in object.iter() { + query_params.push((key.to_string(), serde_json::to_string(value)?)); + } + local_var_req_builder = local_var_req_builder.query(&query_params); + } + {{/isContainer}} } {{/isMap}} {{/isExplode}} @@ -256,11 +268,23 @@ impl {{classname}} for {{classname}}Client { local_var_req_builder = local_var_req_builder.query(¶m_value); {{/isModel}} {{#isMap}} + {{#isContainer}} let mut query_params = Vec::with_capacity(param_value.len()); for (key, value) in param_value.iter() { query_params.push((key.to_string(), serde_json::to_string(value)?)); } local_var_req_builder = local_var_req_builder.query(&query_params); + {{/isContainer}} + {{^isContainer}} + // a free-form object is a serde_json::Value, only walkable through as_object() + if let Some(object) = param_value.as_object() { + let mut query_params = Vec::with_capacity(object.len()); + for (key, value) in object.iter() { + query_params.push((key.to_string(), serde_json::to_string(value)?)); + } + local_var_req_builder = local_var_req_builder.query(&query_params); + } + {{/isContainer}} {{/isMap}} {{/isExplode}} {{/isDeepObject}} diff --git a/modules/openapi-generator/src/main/resources/rust/reqwest/api.mustache b/modules/openapi-generator/src/main/resources/rust/reqwest/api.mustache index 5224b0c4d9b5..95b29699d1d9 100644 --- a/modules/openapi-generator/src/main/resources/rust/reqwest/api.mustache +++ b/modules/openapi-generator/src/main/resources/rust/reqwest/api.mustache @@ -180,11 +180,23 @@ pub {{#supportAsync}}async {{/supportAsync}}fn {{{operationId}}}(configuration: req_builder = req_builder.query(¶m_value); {{/isModel}} {{#isMap}} + {{#isContainer}} let mut query_params = Vec::with_capacity(param_value.len()); for (key, value) in param_value.iter() { query_params.push((key.to_string(), serde_json::to_string(value)?)); } req_builder = req_builder.query(&query_params); + {{/isContainer}} + {{^isContainer}} + // a free-form object is a serde_json::Value, only walkable through as_object() + if let Some(object) = param_value.as_object() { + let mut query_params = Vec::with_capacity(object.len()); + for (key, value) in object.iter() { + query_params.push((key.to_string(), serde_json::to_string(value)?)); + } + req_builder = req_builder.query(&query_params); + } + {{/isContainer}} {{/isMap}} {{/isExplode}} }; @@ -251,11 +263,23 @@ pub {{#supportAsync}}async {{/supportAsync}}fn {{{operationId}}}(configuration: req_builder = req_builder.query(¶m_value); {{/isModel}} {{#isMap}} + {{#isContainer}} let mut query_params = Vec::with_capacity(param_value.len()); for (key, value) in param_value.iter() { query_params.push((key.to_string(), serde_json::to_string(value)?)); } req_builder = req_builder.query(&query_params); + {{/isContainer}} + {{^isContainer}} + // a free-form object is a serde_json::Value, only walkable through as_object() + if let Some(object) = param_value.as_object() { + let mut query_params = Vec::with_capacity(object.len()); + for (key, value) in object.iter() { + query_params.push((key.to_string(), serde_json::to_string(value)?)); + } + req_builder = req_builder.query(&query_params); + } + {{/isContainer}} {{/isMap}} {{/isExplode}} {{/isDeepObject}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java index d4bc7c1845ec..1731b3716e31 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java @@ -293,6 +293,31 @@ public void testIntegerPropertyEnum() throws IOException { TestUtils.assertFileNotContains(outputPath, linearize("#[serde(rename = \"0\")]")); } + @Test + public void testDeepObjectFreeFormQueryParamCompiles() throws IOException { + // the exploded deepObject branch walked every map-flagged parameter with + // .len()/.iter() - fine for a HashMap-typed map, but a bare free-form object is a + // serde_json::Value, which has neither, so the generated crate did not compile + for (String library : new String[] {"reqwest", "reqwest-trait"}) { + Path target = Files.createTempDirectory("test"); + target.toFile().deleteOnExit(); + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("rust") + .setLibrary(library) + .setInputSpec("src/test/resources/3_0/rust/deep-object-free-form-query-param.yaml") + .setSkipOverwrite(false) + .setOutputDir(target.toAbsolutePath().toString().replace("\\", "/")); + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + Path outputPath = Path.of(target.toString(), "/src/apis/default_api.rs"); + TestUtils.assertFileExists(outputPath); + // the free-form parameter walks the Value through as_object(); the typed map + // keeps its direct iteration + TestUtils.assertFileContains(outputPath, "param_value.as_object()"); + TestUtils.assertFileContains(outputPath, "for (key, value) in param_value.iter()"); + TestUtils.assertFileContains(outputPath, "for (key, value) in object.iter()"); + } + } + @Test public void testArrayWithObjectEnumValues() throws IOException { Path target = Files.createTempDirectory("test"); diff --git a/modules/openapi-generator/src/test/resources/3_0/rust/deep-object-free-form-query-param.yaml b/modules/openapi-generator/src/test/resources/3_0/rust/deep-object-free-form-query-param.yaml new file mode 100644 index 000000000000..b69ddf58b717 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/rust/deep-object-free-form-query-param.yaml @@ -0,0 +1,25 @@ +openapi: 3.0.3 +info: + title: deep object params + version: 1.0.0 +paths: + /items: + get: + operationId: listItems + parameters: + - name: filter + in: query + style: deepObject + explode: true + schema: + type: object + additionalProperties: true + - name: extra + in: query + style: deepObject + explode: true + schema: + type: object + responses: + '200': + description: ok From 35907595e1e46abdc8539c3a501e213369d6add0 Mon Sep 17 00:00:00 2001 From: Wiebren Braakman Date: Tue, 8 Sep 2026 11:38:38 +0200 Subject: [PATCH 2/2] [rust] rework: route exploded deepObject maps through parse_deep_object Review pointed out the branch emitted unprefixed json-quoted pairs - not the deepObject wire format the style asks for - and that was true of the pre-existing HashMap iteration too. Both shapes (typed map and free-form serde_json::Value) now take crate::apis::parse_deep_object, the route the non-explode branch already uses: name[key]=value on the wire, one code path, and the Value shape compiles because to_value accepts both. The fixture gains a required-nullable parameter so all patched sites are exercised, and the test registers the generated tree for cleanup. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GcwZ1arjLZNpetHz2a3TJz --- .../resources/rust/reqwest-trait/api.mustache | 42 ++++--------------- .../main/resources/rust/reqwest/api.mustache | 42 ++++--------------- .../codegen/rust/RustClientCodegenTest.java | 19 +++++---- .../deep-object-free-form-query-param.yaml | 10 +++++ 4 files changed, 38 insertions(+), 75 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/rust/reqwest-trait/api.mustache b/modules/openapi-generator/src/main/resources/rust/reqwest-trait/api.mustache index 2a593c656587..093832d31435 100644 --- a/modules/openapi-generator/src/main/resources/rust/reqwest-trait/api.mustache +++ b/modules/openapi-generator/src/main/resources/rust/reqwest-trait/api.mustache @@ -205,24 +205,11 @@ impl {{classname}} for {{classname}}Client { } {{/isModel}} {{#isMap}} + {{!-- one route for the HashMap and the free-form serde_json::Value shape alike, + and the same deepObject wire format the non-explode branch produces --}} if let Some(ref param_value) = {{{paramName}}} { - {{#isContainer}} - let mut query_params = Vec::with_capacity(param_value.len()); - for (key, value) in param_value.iter() { - query_params.push((key.to_string(), serde_json::to_string(value)?)); - } - local_var_req_builder = local_var_req_builder.query(&query_params); - {{/isContainer}} - {{^isContainer}} - // a free-form object is a serde_json::Value, only walkable through as_object() - if let Some(object) = param_value.as_object() { - let mut query_params = Vec::with_capacity(object.len()); - for (key, value) in object.iter() { - query_params.push((key.to_string(), serde_json::to_string(value)?)); - } - local_var_req_builder = local_var_req_builder.query(&query_params); - } - {{/isContainer}} + let params = crate::apis::parse_deep_object("{{{baseName}}}", &serde_json::to_value(param_value)?); + local_var_req_builder = local_var_req_builder.query(¶ms); } {{/isMap}} {{/isExplode}} @@ -268,23 +255,10 @@ impl {{classname}} for {{classname}}Client { local_var_req_builder = local_var_req_builder.query(¶m_value); {{/isModel}} {{#isMap}} - {{#isContainer}} - let mut query_params = Vec::with_capacity(param_value.len()); - for (key, value) in param_value.iter() { - query_params.push((key.to_string(), serde_json::to_string(value)?)); - } - local_var_req_builder = local_var_req_builder.query(&query_params); - {{/isContainer}} - {{^isContainer}} - // a free-form object is a serde_json::Value, only walkable through as_object() - if let Some(object) = param_value.as_object() { - let mut query_params = Vec::with_capacity(object.len()); - for (key, value) in object.iter() { - query_params.push((key.to_string(), serde_json::to_string(value)?)); - } - local_var_req_builder = local_var_req_builder.query(&query_params); - } - {{/isContainer}} + {{!-- one route for the HashMap and the free-form serde_json::Value shape alike, + and the same deepObject wire format the non-explode branch produces --}} + let params = crate::apis::parse_deep_object("{{{baseName}}}", &serde_json::to_value(param_value)?); + local_var_req_builder = local_var_req_builder.query(¶ms); {{/isMap}} {{/isExplode}} {{/isDeepObject}} diff --git a/modules/openapi-generator/src/main/resources/rust/reqwest/api.mustache b/modules/openapi-generator/src/main/resources/rust/reqwest/api.mustache index 95b29699d1d9..8b363e700593 100644 --- a/modules/openapi-generator/src/main/resources/rust/reqwest/api.mustache +++ b/modules/openapi-generator/src/main/resources/rust/reqwest/api.mustache @@ -180,23 +180,10 @@ pub {{#supportAsync}}async {{/supportAsync}}fn {{{operationId}}}(configuration: req_builder = req_builder.query(¶m_value); {{/isModel}} {{#isMap}} - {{#isContainer}} - let mut query_params = Vec::with_capacity(param_value.len()); - for (key, value) in param_value.iter() { - query_params.push((key.to_string(), serde_json::to_string(value)?)); - } - req_builder = req_builder.query(&query_params); - {{/isContainer}} - {{^isContainer}} - // a free-form object is a serde_json::Value, only walkable through as_object() - if let Some(object) = param_value.as_object() { - let mut query_params = Vec::with_capacity(object.len()); - for (key, value) in object.iter() { - query_params.push((key.to_string(), serde_json::to_string(value)?)); - } - req_builder = req_builder.query(&query_params); - } - {{/isContainer}} + {{!-- one route for the HashMap and the free-form serde_json::Value shape alike, + and the same deepObject wire format the non-explode branch produces --}} + let params = crate::apis::parse_deep_object("{{{baseName}}}", &serde_json::to_value(param_value)?); + req_builder = req_builder.query(¶ms); {{/isMap}} {{/isExplode}} }; @@ -263,23 +250,10 @@ pub {{#supportAsync}}async {{/supportAsync}}fn {{{operationId}}}(configuration: req_builder = req_builder.query(¶m_value); {{/isModel}} {{#isMap}} - {{#isContainer}} - let mut query_params = Vec::with_capacity(param_value.len()); - for (key, value) in param_value.iter() { - query_params.push((key.to_string(), serde_json::to_string(value)?)); - } - req_builder = req_builder.query(&query_params); - {{/isContainer}} - {{^isContainer}} - // a free-form object is a serde_json::Value, only walkable through as_object() - if let Some(object) = param_value.as_object() { - let mut query_params = Vec::with_capacity(object.len()); - for (key, value) in object.iter() { - query_params.push((key.to_string(), serde_json::to_string(value)?)); - } - req_builder = req_builder.query(&query_params); - } - {{/isContainer}} + {{!-- one route for the HashMap and the free-form serde_json::Value shape alike, + and the same deepObject wire format the non-explode branch produces --}} + let params = crate::apis::parse_deep_object("{{{baseName}}}", &serde_json::to_value(param_value)?); + req_builder = req_builder.query(¶ms); {{/isMap}} {{/isExplode}} {{/isDeepObject}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java index 1731b3716e31..bebb54132710 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java @@ -297,7 +297,9 @@ public void testIntegerPropertyEnum() throws IOException { public void testDeepObjectFreeFormQueryParamCompiles() throws IOException { // the exploded deepObject branch walked every map-flagged parameter with // .len()/.iter() - fine for a HashMap-typed map, but a bare free-form object is a - // serde_json::Value, which has neither, so the generated crate did not compile + // serde_json::Value, which has neither, so the generated crate did not compile; + // both shapes now take parse_deep_object, the route the non-explode branch already + // uses, which also yields the deepObject wire format the style asks for for (String library : new String[] {"reqwest", "reqwest-trait"}) { Path target = Files.createTempDirectory("test"); target.toFile().deleteOnExit(); @@ -307,14 +309,17 @@ public void testDeepObjectFreeFormQueryParamCompiles() throws IOException { .setInputSpec("src/test/resources/3_0/rust/deep-object-free-form-query-param.yaml") .setSkipOverwrite(false) .setOutputDir(target.toAbsolutePath().toString().replace("\\", "/")); - new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + List files = new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); Path outputPath = Path.of(target.toString(), "/src/apis/default_api.rs"); TestUtils.assertFileExists(outputPath); - // the free-form parameter walks the Value through as_object(); the typed map - // keeps its direct iteration - TestUtils.assertFileContains(outputPath, "param_value.as_object()"); - TestUtils.assertFileContains(outputPath, "for (key, value) in param_value.iter()"); - TestUtils.assertFileContains(outputPath, "for (key, value) in object.iter()"); + // the optional typed map, the optional free-form object, and the + // required-nullable map all route through parse_deep_object + TestUtils.assertFileContains(outputPath, "crate::apis::parse_deep_object(\"filter\""); + TestUtils.assertFileContains(outputPath, "crate::apis::parse_deep_object(\"extra\""); + TestUtils.assertFileContains(outputPath, "crate::apis::parse_deep_object(\"scope\""); + TestUtils.assertFileNotContains(outputPath, "param_value.len()"); + TestUtils.assertFileNotContains(outputPath, "param_value.iter()"); } } diff --git a/modules/openapi-generator/src/test/resources/3_0/rust/deep-object-free-form-query-param.yaml b/modules/openapi-generator/src/test/resources/3_0/rust/deep-object-free-form-query-param.yaml index b69ddf58b717..5780ef2bf2da 100644 --- a/modules/openapi-generator/src/test/resources/3_0/rust/deep-object-free-form-query-param.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/rust/deep-object-free-form-query-param.yaml @@ -20,6 +20,16 @@ paths: explode: true schema: type: object + - name: scope + in: query + required: true + style: deepObject + explode: true + schema: + type: object + nullable: true + additionalProperties: + type: string responses: '200': description: ok