Skip to content

Commit c812e61

Browse files
fix(python-aiohttp, python-blueplanet, python-flask): map params iterate .items(); free-form params skip from_dict (#25361)
* fix(python-aiohttp, Connexion servers): iterate .items() for map params and skip from_dict on free-form params In python-aiohttp controller.mustache, map parameter comprehensions iterated over dict keys instead of .items(), raising ValueError at runtime when unpacking (k, v). Add .items() to match python-blueplanet and python-flask. In python-aiohttp, python-blueplanet, and python-flask controller.mustache, guard non-container model deserialization with {{^isFreeFormObject}} and {{^isAnyType}} so free-form object (schema: {type: object}) and untyped (schema: {}) parameters are not deserialized via .from_dict(). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(python-aiohttp, Connexion servers): scope skip-from-dict to object dataType and guard None map params Composed schemas without an explicit type (such as allOf or oneOf model $refs) also set CodegenParameter.isAnyType=true while setting dataType to the model class name, so guarding controller templates directly with {{^isAnyType}} skipped Model.from_dict() on composed model parameters. Set the vendor extension x-python-connexion-skip-from-dict in AbstractPythonConnexionServerCodegen#postProcessOperationsWithModels only when (isFreeFormObject || isAnyType) and dataType is "object", and guard the python-aiohttp map parameter comprehensions with `if <param> is not None:`. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(python-connexion): assert addl parameter handling in composed query param test Verify that the WithAddl query parameter (which defines both properties and additionalProperties: {type: string}) is classified by DefaultCodegen as a map container (`isMap=true`, `isContainer=true`) and therefore emits `:type addl: Dict[str, str]` in the docstring without a `.from_dict()` conversion line in the controller body, matching 7.26.0 output. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 33dd56b commit c812e61

7 files changed

Lines changed: 328 additions & 3 deletions

File tree

‎modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonConnexionServerCodegen.java‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,11 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
724724
}
725725
}
726726
}
727+
for (CodegenParameter param : operation.allParams) {
728+
if ((param.isFreeFormObject || param.isAnyType) && "object".equals(param.dataType)) {
729+
param.vendorExtensions.put("x-python-connexion-skip-from-dict", Boolean.TRUE);
730+
}
731+
}
727732
}
728733
return objs;
729734
}

‎modules/openapi-generator/src/main/resources/python-aiohttp/controller.mustache‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ async def {{operationId}}(request: web.Request, {{#allParams}}{{paramName}}{{^re
6767
{{^isPrimitiveType}}
6868
{{^isFile}}
6969
{{^isUuid}}
70+
{{^vendorExtensions.x-python-connexion-skip-from-dict}}
7071
{{paramName}} = {{baseType}}.from_dict({{paramName}})
72+
{{/vendorExtensions.x-python-connexion-skip-from-dict}}
7173
{{/isUuid}}
7274
{{/isFile}}
7375
{{/isPrimitiveType}}
@@ -88,13 +90,16 @@ async def {{operationId}}(request: web.Request, {{#allParams}}{{paramName}}{{^re
8890
{{#isMap}}
8991
{{#items}}
9092
{{#isDate}}
91-
{{paramName}} = {k: util.deserialize_date(v) for k, v in {{paramName}}}
93+
if {{paramName}} is not None:
94+
{{paramName}} = {k: util.deserialize_date(v) for k, v in {{paramName}}.items()}
9295
{{/isDate}}
9396
{{#isDateTime}}
94-
{{paramName}} = {k: util.deserialize_datetime(v) for k, v in {{paramName}}}
97+
if {{paramName}} is not None:
98+
{{paramName}} = {k: util.deserialize_datetime(v) for k, v in {{paramName}}.items()}
9599
{{/isDateTime}}
96100
{{#complexType}}
97-
{{paramName}} = {k: {{baseType}}.from_dict(v) for k, v in {{paramName}}}
101+
if {{paramName}} is not None:
102+
{{paramName}} = {k: {{baseType}}.from_dict(v) for k, v in {{paramName}}.items()}
98103
{{/complexType}}
99104
{{/items}}
100105
{{/isMap}}

‎modules/openapi-generator/src/main/resources/python-blueplanet/app/{{packageName}}/controllers/controller.mustache‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,10 @@ def {{operationId}}({{#allParams}}{{paramName}}{{^required}}=None{{/required}}{{
6767
{{^isPrimitiveType}}
6868
{{^isFile}}
6969
{{^isUuid}}
70+
{{^vendorExtensions.x-python-connexion-skip-from-dict}}
7071
if connexion.request.is_json:
7172
{{paramName}} = {{baseType}}.from_dict(connexion.request.get_json()) # noqa: E501
73+
{{/vendorExtensions.x-python-connexion-skip-from-dict}}
7274
{{/isUuid}}
7375
{{/isFile}}
7476
{{/isPrimitiveType}}

‎modules/openapi-generator/src/main/resources/python-flask/controller.mustache‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,10 @@ def {{operationId}}({{#allParams}}{{^isBodyParam}}{{paramName}}{{/isBodyParam}}{
7373
{{^isPrimitiveType}}
7474
{{^isFile}}
7575
{{^isUuid}}
76+
{{^vendorExtensions.x-python-connexion-skip-from-dict}}
7677
if flask.request.is_json:
7778
{{paramName}} = {{baseType}}{{^baseType}}{{#dataType}} {{.}}{{/dataType}}{{/baseType}}.from_dict(flask.request.get_json()) # noqa: E501
79+
{{/vendorExtensions.x-python-connexion-skip-from-dict}}
7880
{{/isUuid}}
7981
{{/isFile}}
8082
{{/isPrimitiveType}}

‎modules/openapi-generator/src/test/java/org/openapitools/codegen/python/AbstractPythonConnexionServerCodegenTest.java‎

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,36 @@
11
package org.openapitools.codegen.python;
22

33
import com.google.common.collect.ImmutableMap;
4+
import io.swagger.parser.OpenAPIParser;
5+
import io.swagger.v3.oas.models.OpenAPI;
6+
import io.swagger.v3.oas.models.parameters.Parameter;
7+
import io.swagger.v3.parser.core.models.ParseOptions;
8+
import org.apache.commons.io.FileUtils;
49
import org.apache.commons.lang3.StringUtils;
510
import org.openapitools.codegen.CodegenConstants;
11+
import org.openapitools.codegen.CodegenParameter;
12+
import org.openapitools.codegen.DefaultGenerator;
13+
import org.openapitools.codegen.config.CodegenConfigurator;
614
import org.openapitools.codegen.languages.AbstractPythonConnexionServerCodegen;
15+
import org.openapitools.codegen.languages.PythonAiohttpConnexionServerCodegen;
716
import org.testng.annotations.DataProvider;
817
import org.testng.annotations.Test;
918

1019
import java.io.File;
20+
import java.io.IOException;
21+
import java.nio.file.Files;
22+
import java.nio.file.Path;
23+
import java.nio.file.Paths;
1124
import java.util.Collections;
25+
import java.util.HashSet;
26+
import java.util.List;
1227
import java.util.Map;
1328
import java.util.Objects;
1429

1530
import static org.assertj.core.api.Assertions.assertThat;
31+
import static org.openapitools.codegen.TestUtils.assertFileContains;
32+
import static org.openapitools.codegen.TestUtils.assertFileExists;
33+
import static org.openapitools.codegen.TestUtils.assertFileNotContains;
1634
import static org.openapitools.codegen.languages.AbstractPythonConnexionServerCodegen.*;
1735

1836
public class AbstractPythonConnexionServerCodegenTest {
@@ -142,4 +160,176 @@ private static String platformAgnosticPath(String... nodes) {
142160
return StringUtils.join(nodes, File.separatorChar);
143161
}
144162

163+
@Test(description = "python-aiohttp map parameters should iterate .items()")
164+
public void testAiohttpMapParametersIterateItems() throws IOException {
165+
String specPath = "src/test/resources/3_1/python/map-and-freeform-params.yaml";
166+
167+
File aiohttpOutput = Files.createTempDirectory("test-aiohttp-map-params").toFile().getCanonicalFile();
168+
try {
169+
CodegenConfigurator aiohttpConfig = new CodegenConfigurator()
170+
.setGeneratorName("python-aiohttp")
171+
.setInputSpec(specPath)
172+
.setOutputDir(aiohttpOutput.getAbsolutePath());
173+
new DefaultGenerator().opts(aiohttpConfig.toClientOptInput()).generate();
174+
175+
Path aiohttpController = Paths.get(
176+
aiohttpOutput.getAbsolutePath(),
177+
"openapi_server",
178+
"controllers",
179+
"default_controller.py");
180+
assertFileExists(aiohttpController);
181+
assertFileContains(
182+
aiohttpController,
183+
" if date_map is not None:\n date_map = {k: util.deserialize_date(v) for k, v in date_map.items()}",
184+
" if datetime_map is not None:\n datetime_map = {k: util.deserialize_datetime(v) for k, v in datetime_map.items()}",
185+
" if model_map is not None:\n model_map = {k: Tag.from_dict(v) for k, v in model_map.items()}");
186+
assertFileNotContains(
187+
aiohttpController,
188+
"for k, v in date_map}",
189+
"for k, v in datetime_map}",
190+
"for k, v in model_map}");
191+
} finally {
192+
FileUtils.deleteDirectory(aiohttpOutput);
193+
}
194+
}
195+
196+
@Test(description = "Connexion servers should skip from_dict only on untyped/free-form object parameters and keep from_dict on composed/model parameters")
197+
public void testFreeFormAndAnyTypeParametersDoNotCallFromDict() throws IOException {
198+
String specPath = "src/test/resources/3_1/python/map-and-freeform-params.yaml";
199+
String composedSpecPath = "src/test/resources/3_1/python/composed-query-params.yaml";
200+
201+
OpenAPI openAPI = new OpenAPIParser().readLocation(specPath, null, new ParseOptions()).getOpenAPI();
202+
PythonAiohttpConnexionServerCodegen codegen = new PythonAiohttpConnexionServerCodegen();
203+
codegen.setOpenAPI(openAPI);
204+
List<Parameter> testParameters = openAPI.getPaths().get("/test").getGet().getParameters();
205+
CodegenParameter freeFormParam = codegen.fromParameter(testParameters.get(3), new HashSet<>());
206+
CodegenParameter anyValParam = codegen.fromParameter(testParameters.get(4), new HashSet<>());
207+
208+
assertThat(freeFormParam.paramName).isEqualTo("free_form");
209+
assertThat(freeFormParam.isFreeFormObject).isTrue();
210+
assertThat(freeFormParam.isAnyType).isFalse();
211+
assertThat(freeFormParam.isContainer).isFalse();
212+
assertThat(freeFormParam.isPrimitiveType).isFalse();
213+
assertThat(freeFormParam.dataType).isEqualTo("object");
214+
215+
assertThat(anyValParam.paramName).isEqualTo("any_val");
216+
assertThat(anyValParam.isFreeFormObject).isFalse();
217+
assertThat(anyValParam.isAnyType).isTrue();
218+
assertThat(anyValParam.isContainer).isFalse();
219+
assertThat(anyValParam.isPrimitiveType).isFalse();
220+
assertThat(anyValParam.dataType).isEqualTo("object");
221+
222+
OpenAPI composedOpenAPI = new OpenAPIParser().readLocation(composedSpecPath, null, new ParseOptions()).getOpenAPI();
223+
PythonAiohttpConnexionServerCodegen composedCodegen = new PythonAiohttpConnexionServerCodegen();
224+
composedCodegen.setOpenAPI(composedOpenAPI);
225+
List<Parameter> probeParameters = composedOpenAPI.getPaths().get("/t").getGet().getParameters();
226+
CodegenParameter composedParam = composedCodegen.fromParameter(probeParameters.get(0), new HashSet<>());
227+
CodegenParameter oneOfParam = composedCodegen.fromParameter(probeParameters.get(1), new HashSet<>());
228+
CodegenParameter addlParam = composedCodegen.fromParameter(probeParameters.get(2), new HashSet<>());
229+
assertThat(composedParam.isAnyType).isTrue();
230+
assertThat(composedParam.dataType).isEqualTo("Composed");
231+
assertThat(oneOfParam.isAnyType).isTrue();
232+
assertThat(oneOfParam.dataType).isEqualTo("OneOfM");
233+
assertThat(addlParam.paramName).isEqualTo("addl");
234+
assertThat(addlParam.isMap).isTrue();
235+
assertThat(addlParam.isContainer).isTrue();
236+
assertThat(addlParam.isFreeFormObject).isFalse();
237+
assertThat(addlParam.isAnyType).isFalse();
238+
assertThat(addlParam.dataType).isEqualTo("WithAddl");
239+
240+
File aiohttpOutput = Files.createTempDirectory("test-aiohttp-freeform-params").toFile().getCanonicalFile();
241+
try {
242+
CodegenConfigurator aiohttpConfig = new CodegenConfigurator()
243+
.setGeneratorName("python-aiohttp")
244+
.setInputSpec(specPath)
245+
.setOutputDir(aiohttpOutput.getAbsolutePath());
246+
new DefaultGenerator().opts(aiohttpConfig.toClientOptInput()).generate();
247+
248+
Path aiohttpController = Paths.get(
249+
aiohttpOutput.getAbsolutePath(),
250+
"openapi_server",
251+
"controllers",
252+
"default_controller.py");
253+
assertFileExists(aiohttpController);
254+
assertFileNotContains(
255+
aiohttpController,
256+
"free_form = ",
257+
"any_val = ");
258+
} finally {
259+
FileUtils.deleteDirectory(aiohttpOutput);
260+
}
261+
262+
File blueplanetOutput = Files.createTempDirectory("test-blueplanet-freeform-params").toFile().getCanonicalFile();
263+
try {
264+
CodegenConfigurator blueplanetConfig = new CodegenConfigurator()
265+
.setGeneratorName("python-blueplanet")
266+
.setInputSpec(specPath)
267+
.setOutputDir(blueplanetOutput.getAbsolutePath());
268+
new DefaultGenerator().opts(blueplanetConfig.toClientOptInput()).generate();
269+
270+
Path blueplanetController = Paths.get(
271+
blueplanetOutput.getAbsolutePath(),
272+
"app",
273+
"openapi_server",
274+
"controllers",
275+
"default_controller.py");
276+
assertFileExists(blueplanetController);
277+
assertFileNotContains(
278+
blueplanetController,
279+
"free_form = ",
280+
"any_val = ");
281+
} finally {
282+
FileUtils.deleteDirectory(blueplanetOutput);
283+
}
284+
285+
File flaskOutput = Files.createTempDirectory("test-flask-freeform-params").toFile().getCanonicalFile();
286+
try {
287+
CodegenConfigurator flaskConfig = new CodegenConfigurator()
288+
.setGeneratorName("python-flask")
289+
.setInputSpec(specPath)
290+
.setOutputDir(flaskOutput.getAbsolutePath());
291+
new DefaultGenerator().opts(flaskConfig.toClientOptInput()).generate();
292+
293+
Path flaskController = Paths.get(
294+
flaskOutput.getAbsolutePath(),
295+
"openapi_server",
296+
"controllers",
297+
"default_controller.py");
298+
assertFileExists(flaskController);
299+
assertFileNotContains(
300+
flaskController,
301+
"free_form =",
302+
"any_val =");
303+
} finally {
304+
FileUtils.deleteDirectory(flaskOutput);
305+
}
306+
307+
File flaskComposedOutput = Files.createTempDirectory("test-flask-composed-params").toFile().getCanonicalFile();
308+
try {
309+
CodegenConfigurator flaskComposedConfig = new CodegenConfigurator()
310+
.setGeneratorName("python-flask")
311+
.setInputSpec(composedSpecPath)
312+
.setOutputDir(flaskComposedOutput.getAbsolutePath());
313+
new DefaultGenerator().opts(flaskComposedConfig.toClientOptInput()).generate();
314+
315+
Path flaskComposedController = Paths.get(
316+
flaskComposedOutput.getAbsolutePath(),
317+
"openapi_server",
318+
"controllers",
319+
"default_controller.py");
320+
assertFileExists(flaskComposedController);
321+
assertFileContains(
322+
flaskComposedController,
323+
":type addl: Dict[str, str]",
324+
"composed = Composed.from_dict(flask.request.get_json()) # noqa: E501",
325+
"oneof = OneOfM.from_dict(flask.request.get_json()) # noqa: E501",
326+
"plain = Pet.from_dict(flask.request.get_json()) # noqa: E501");
327+
assertFileNotContains(
328+
flaskComposedController,
329+
"addl =");
330+
} finally {
331+
FileUtils.deleteDirectory(flaskComposedOutput);
332+
}
333+
}
334+
145335
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
openapi: 3.1.0
2+
3+
info:
4+
title: Composed and model query parameter test
5+
version: 1.0.0
6+
7+
paths:
8+
/t:
9+
get:
10+
operationId: probe
11+
parameters:
12+
- name: composed
13+
in: query
14+
required: true
15+
content:
16+
application/json:
17+
schema:
18+
$ref: '#/components/schemas/Composed'
19+
- name: oneof
20+
in: query
21+
required: true
22+
content:
23+
application/json:
24+
schema:
25+
$ref: '#/components/schemas/OneOfM'
26+
- name: addl
27+
in: query
28+
required: true
29+
content:
30+
application/json:
31+
schema:
32+
$ref: '#/components/schemas/WithAddl'
33+
- name: plain
34+
in: query
35+
required: true
36+
content:
37+
application/json:
38+
schema:
39+
$ref: '#/components/schemas/Pet'
40+
responses:
41+
'200':
42+
description: OK
43+
44+
components:
45+
schemas:
46+
Pet:
47+
type: object
48+
properties:
49+
name:
50+
type: string
51+
Tag:
52+
type: object
53+
properties:
54+
label:
55+
type: string
56+
Composed:
57+
allOf:
58+
- $ref: '#/components/schemas/Pet'
59+
- $ref: '#/components/schemas/Tag'
60+
OneOfM:
61+
oneOf:
62+
- $ref: '#/components/schemas/Pet'
63+
- $ref: '#/components/schemas/Tag'
64+
WithAddl:
65+
type: object
66+
properties:
67+
a:
68+
type: string
69+
additionalProperties:
70+
type: string

0 commit comments

Comments
 (0)