From 9a23059fc3c3d3db8b7f31ab18b40023f47088d5 Mon Sep 17 00:00:00 2001 From: khayashi4337 Date: Thu, 22 Oct 2026 13:47:33 +0900 Subject: [PATCH 1/3] 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> --- .../python-aiohttp/controller.mustache | 10 +- .../controllers/controller.mustache | 4 + .../python-flask/controller.mustache | 4 + ...tractPythonConnexionServerCodegenTest.java | 144 ++++++++++++++++++ .../3_1/python/map-and-freeform-params.yaml | 51 +++++++ 5 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_1/python/map-and-freeform-params.yaml diff --git a/modules/openapi-generator/src/main/resources/python-aiohttp/controller.mustache b/modules/openapi-generator/src/main/resources/python-aiohttp/controller.mustache index 170f65ed4b19..4d68a3b54a3c 100644 --- a/modules/openapi-generator/src/main/resources/python-aiohttp/controller.mustache +++ b/modules/openapi-generator/src/main/resources/python-aiohttp/controller.mustache @@ -67,7 +67,11 @@ async def {{operationId}}(request: web.Request, {{#allParams}}{{paramName}}{{^re {{^isPrimitiveType}} {{^isFile}} {{^isUuid}} + {{^isFreeFormObject}} + {{^isAnyType}} {{paramName}} = {{baseType}}.from_dict({{paramName}}) + {{/isAnyType}} + {{/isFreeFormObject}} {{/isUuid}} {{/isFile}} {{/isPrimitiveType}} @@ -88,13 +92,13 @@ async def {{operationId}}(request: web.Request, {{#allParams}}{{paramName}}{{^re {{#isMap}} {{#items}} {{#isDate}} - {{paramName}} = {k: util.deserialize_date(v) for k, v in {{paramName}}} + {{paramName}} = {k: util.deserialize_date(v) for k, v in {{paramName}}.items()} {{/isDate}} {{#isDateTime}} - {{paramName}} = {k: util.deserialize_datetime(v) for k, v in {{paramName}}} + {{paramName}} = {k: util.deserialize_datetime(v) for k, v in {{paramName}}.items()} {{/isDateTime}} {{#complexType}} - {{paramName}} = {k: {{baseType}}.from_dict(v) for k, v in {{paramName}}} + {{paramName}} = {k: {{baseType}}.from_dict(v) for k, v in {{paramName}}.items()} {{/complexType}} {{/items}} {{/isMap}} diff --git a/modules/openapi-generator/src/main/resources/python-blueplanet/app/{{packageName}}/controllers/controller.mustache b/modules/openapi-generator/src/main/resources/python-blueplanet/app/{{packageName}}/controllers/controller.mustache index afba1f7cfefb..49d531b851a7 100644 --- a/modules/openapi-generator/src/main/resources/python-blueplanet/app/{{packageName}}/controllers/controller.mustache +++ b/modules/openapi-generator/src/main/resources/python-blueplanet/app/{{packageName}}/controllers/controller.mustache @@ -67,8 +67,12 @@ def {{operationId}}({{#allParams}}{{paramName}}{{^required}}=None{{/required}}{{ {{^isPrimitiveType}} {{^isFile}} {{^isUuid}} + {{^isFreeFormObject}} + {{^isAnyType}} if connexion.request.is_json: {{paramName}} = {{baseType}}.from_dict(connexion.request.get_json()) # noqa: E501 + {{/isAnyType}} + {{/isFreeFormObject}} {{/isUuid}} {{/isFile}} {{/isPrimitiveType}} diff --git a/modules/openapi-generator/src/main/resources/python-flask/controller.mustache b/modules/openapi-generator/src/main/resources/python-flask/controller.mustache index d5118c610a74..32066a2d0b75 100644 --- a/modules/openapi-generator/src/main/resources/python-flask/controller.mustache +++ b/modules/openapi-generator/src/main/resources/python-flask/controller.mustache @@ -73,8 +73,12 @@ def {{operationId}}({{#allParams}}{{^isBodyParam}}{{paramName}}{{/isBodyParam}}{ {{^isPrimitiveType}} {{^isFile}} {{^isUuid}} + {{^isFreeFormObject}} + {{^isAnyType}} if flask.request.is_json: {{paramName}} = {{baseType}}{{^baseType}}{{#dataType}} {{.}}{{/dataType}}{{/baseType}}.from_dict(flask.request.get_json()) # noqa: E501 + {{/isAnyType}} + {{/isFreeFormObject}} {{/isUuid}} {{/isFile}} {{/isPrimitiveType}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/AbstractPythonConnexionServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/AbstractPythonConnexionServerCodegenTest.java index c4133bf1ccd5..ab14524209c1 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/AbstractPythonConnexionServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/AbstractPythonConnexionServerCodegenTest.java @@ -1,18 +1,36 @@ package org.openapitools.codegen.python; import com.google.common.collect.ImmutableMap; +import io.swagger.parser.OpenAPIParser; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.parser.core.models.ParseOptions; +import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; import org.openapitools.codegen.CodegenConstants; +import org.openapitools.codegen.CodegenParameter; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.config.CodegenConfigurator; import org.openapitools.codegen.languages.AbstractPythonConnexionServerCodegen; +import org.openapitools.codegen.languages.PythonAiohttpConnexionServerCodegen; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Collections; +import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Objects; import static org.assertj.core.api.Assertions.assertThat; +import static org.openapitools.codegen.TestUtils.assertFileContains; +import static org.openapitools.codegen.TestUtils.assertFileExists; +import static org.openapitools.codegen.TestUtils.assertFileNotContains; import static org.openapitools.codegen.languages.AbstractPythonConnexionServerCodegen.*; public class AbstractPythonConnexionServerCodegenTest { @@ -142,4 +160,130 @@ private static String platformAgnosticPath(String... nodes) { return StringUtils.join(nodes, File.separatorChar); } + @Test(description = "python-aiohttp map parameters should iterate .items()") + public void testAiohttpMapParametersIterateItems() throws IOException { + String specPath = "src/test/resources/3_1/python/map-and-freeform-params.yaml"; + + File aiohttpOutput = Files.createTempDirectory("test-aiohttp-map-params").toFile().getCanonicalFile(); + try { + CodegenConfigurator aiohttpConfig = new CodegenConfigurator() + .setGeneratorName("python-aiohttp") + .setInputSpec(specPath) + .setOutputDir(aiohttpOutput.getAbsolutePath()); + new DefaultGenerator().opts(aiohttpConfig.toClientOptInput()).generate(); + + Path aiohttpController = Paths.get( + aiohttpOutput.getAbsolutePath(), + "openapi_server", + "controllers", + "default_controller.py"); + assertFileExists(aiohttpController); + assertFileContains( + aiohttpController, + "date_map = {k: util.deserialize_date(v) for k, v in date_map.items()}", + "datetime_map = {k: util.deserialize_datetime(v) for k, v in datetime_map.items()}", + "model_map = {k: Tag.from_dict(v) for k, v in model_map.items()}"); + assertFileNotContains( + aiohttpController, + "for k, v in date_map}", + "for k, v in datetime_map}", + "for k, v in model_map}"); + } finally { + FileUtils.deleteDirectory(aiohttpOutput); + } + } + + @Test(description = "Connexion servers should not call from_dict on free-form or any-type parameters") + public void testFreeFormAndAnyTypeParametersDoNotCallFromDict() throws IOException { + String specPath = "src/test/resources/3_1/python/map-and-freeform-params.yaml"; + + OpenAPI openAPI = new OpenAPIParser().readLocation(specPath, null, new ParseOptions()).getOpenAPI(); + PythonAiohttpConnexionServerCodegen codegen = new PythonAiohttpConnexionServerCodegen(); + codegen.setOpenAPI(openAPI); + List parameters = openAPI.getPaths().get("/test").getGet().getParameters(); + CodegenParameter freeFormParam = codegen.fromParameter(parameters.get(3), new HashSet<>()); + CodegenParameter anyValParam = codegen.fromParameter(parameters.get(4), new HashSet<>()); + + assertThat(freeFormParam.paramName).isEqualTo("free_form"); + assertThat(freeFormParam.isFreeFormObject).isTrue(); + assertThat(freeFormParam.isAnyType).isFalse(); + assertThat(freeFormParam.isContainer).isFalse(); + assertThat(freeFormParam.isPrimitiveType).isFalse(); + assertThat(freeFormParam.dataType).isEqualTo("object"); + + assertThat(anyValParam.paramName).isEqualTo("any_val"); + assertThat(anyValParam.isFreeFormObject).isFalse(); + assertThat(anyValParam.isAnyType).isTrue(); + assertThat(anyValParam.isContainer).isFalse(); + assertThat(anyValParam.isPrimitiveType).isFalse(); + assertThat(anyValParam.dataType).isEqualTo("object"); + + File aiohttpOutput = Files.createTempDirectory("test-aiohttp-freeform-params").toFile().getCanonicalFile(); + try { + CodegenConfigurator aiohttpConfig = new CodegenConfigurator() + .setGeneratorName("python-aiohttp") + .setInputSpec(specPath) + .setOutputDir(aiohttpOutput.getAbsolutePath()); + new DefaultGenerator().opts(aiohttpConfig.toClientOptInput()).generate(); + + Path aiohttpController = Paths.get( + aiohttpOutput.getAbsolutePath(), + "openapi_server", + "controllers", + "default_controller.py"); + assertFileExists(aiohttpController); + assertFileNotContains( + aiohttpController, + "free_form = ", + "any_val = "); + } finally { + FileUtils.deleteDirectory(aiohttpOutput); + } + + File blueplanetOutput = Files.createTempDirectory("test-blueplanet-freeform-params").toFile().getCanonicalFile(); + try { + CodegenConfigurator blueplanetConfig = new CodegenConfigurator() + .setGeneratorName("python-blueplanet") + .setInputSpec(specPath) + .setOutputDir(blueplanetOutput.getAbsolutePath()); + new DefaultGenerator().opts(blueplanetConfig.toClientOptInput()).generate(); + + Path blueplanetController = Paths.get( + blueplanetOutput.getAbsolutePath(), + "app", + "openapi_server", + "controllers", + "default_controller.py"); + assertFileExists(blueplanetController); + assertFileNotContains( + blueplanetController, + "free_form = ", + "any_val = "); + } finally { + FileUtils.deleteDirectory(blueplanetOutput); + } + + File flaskOutput = Files.createTempDirectory("test-flask-freeform-params").toFile().getCanonicalFile(); + try { + CodegenConfigurator flaskConfig = new CodegenConfigurator() + .setGeneratorName("python-flask") + .setInputSpec(specPath) + .setOutputDir(flaskOutput.getAbsolutePath()); + new DefaultGenerator().opts(flaskConfig.toClientOptInput()).generate(); + + Path flaskController = Paths.get( + flaskOutput.getAbsolutePath(), + "openapi_server", + "controllers", + "default_controller.py"); + assertFileExists(flaskController); + assertFileNotContains( + flaskController, + "free_form =", + "any_val ="); + } finally { + FileUtils.deleteDirectory(flaskOutput); + } + } + } diff --git a/modules/openapi-generator/src/test/resources/3_1/python/map-and-freeform-params.yaml b/modules/openapi-generator/src/test/resources/3_1/python/map-and-freeform-params.yaml new file mode 100644 index 000000000000..248151bf6638 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/python/map-and-freeform-params.yaml @@ -0,0 +1,51 @@ +openapi: 3.1.0 + +info: + title: Map and free-form parameter test + version: 1.0.0 + +paths: + /test: + get: + operationId: testParams + parameters: + - name: date_map + in: query + schema: + type: object + additionalProperties: + type: string + format: date + - name: datetime_map + in: query + schema: + type: object + additionalProperties: + type: string + format: date-time + - name: model_map + in: query + schema: + type: object + additionalProperties: + $ref: '#/components/schemas/Tag' + - name: free_form + in: query + schema: + type: object + - name: any_val + in: query + schema: {} + responses: + '200': + description: OK + +components: + schemas: + Tag: + type: object + properties: + id: + type: integer + name: + type: string From ddd038404d0e2252af8527fd6100277882b82086 Mon Sep 17 00:00:00 2001 From: khayashi4337 Date: Thu, 22 Oct 2026 16:38:20 +0900 Subject: [PATCH 2/3] 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 is not None:`. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../AbstractPythonConnexionServerCodegen.java | 5 ++ .../python-aiohttp/controller.mustache | 15 ++-- .../controllers/controller.mustache | 6 +- .../python-flask/controller.mustache | 6 +- ...tractPythonConnexionServerCodegenTest.java | 49 +++++++++++-- .../3_1/python/composed-query-params.yaml | 70 +++++++++++++++++++ 6 files changed, 129 insertions(+), 22 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_1/python/composed-query-params.yaml diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonConnexionServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonConnexionServerCodegen.java index f7b7202d543a..a286e60e01a0 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonConnexionServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractPythonConnexionServerCodegen.java @@ -724,6 +724,11 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List parameters = openAPI.getPaths().get("/test").getGet().getParameters(); - CodegenParameter freeFormParam = codegen.fromParameter(parameters.get(3), new HashSet<>()); - CodegenParameter anyValParam = codegen.fromParameter(parameters.get(4), new HashSet<>()); + List testParameters = openAPI.getPaths().get("/test").getGet().getParameters(); + CodegenParameter freeFormParam = codegen.fromParameter(testParameters.get(3), new HashSet<>()); + CodegenParameter anyValParam = codegen.fromParameter(testParameters.get(4), new HashSet<>()); assertThat(freeFormParam.paramName).isEqualTo("free_form"); assertThat(freeFormParam.isFreeFormObject).isTrue(); @@ -218,6 +219,17 @@ public void testFreeFormAndAnyTypeParametersDoNotCallFromDict() throws IOExcepti assertThat(anyValParam.isPrimitiveType).isFalse(); assertThat(anyValParam.dataType).isEqualTo("object"); + OpenAPI composedOpenAPI = new OpenAPIParser().readLocation(composedSpecPath, null, new ParseOptions()).getOpenAPI(); + PythonAiohttpConnexionServerCodegen composedCodegen = new PythonAiohttpConnexionServerCodegen(); + composedCodegen.setOpenAPI(composedOpenAPI); + List probeParameters = composedOpenAPI.getPaths().get("/t").getGet().getParameters(); + CodegenParameter composedParam = composedCodegen.fromParameter(probeParameters.get(0), new HashSet<>()); + CodegenParameter oneOfParam = composedCodegen.fromParameter(probeParameters.get(1), new HashSet<>()); + assertThat(composedParam.isAnyType).isTrue(); + assertThat(composedParam.dataType).isEqualTo("Composed"); + assertThat(oneOfParam.isAnyType).isTrue(); + assertThat(oneOfParam.dataType).isEqualTo("OneOfM"); + File aiohttpOutput = Files.createTempDirectory("test-aiohttp-freeform-params").toFile().getCanonicalFile(); try { CodegenConfigurator aiohttpConfig = new CodegenConfigurator() @@ -284,6 +296,29 @@ public void testFreeFormAndAnyTypeParametersDoNotCallFromDict() throws IOExcepti } finally { FileUtils.deleteDirectory(flaskOutput); } + + File flaskComposedOutput = Files.createTempDirectory("test-flask-composed-params").toFile().getCanonicalFile(); + try { + CodegenConfigurator flaskComposedConfig = new CodegenConfigurator() + .setGeneratorName("python-flask") + .setInputSpec(composedSpecPath) + .setOutputDir(flaskComposedOutput.getAbsolutePath()); + new DefaultGenerator().opts(flaskComposedConfig.toClientOptInput()).generate(); + + Path flaskComposedController = Paths.get( + flaskComposedOutput.getAbsolutePath(), + "openapi_server", + "controllers", + "default_controller.py"); + assertFileExists(flaskComposedController); + assertFileContains( + flaskComposedController, + "composed = Composed.from_dict(flask.request.get_json()) # noqa: E501", + "oneof = OneOfM.from_dict(flask.request.get_json()) # noqa: E501", + "plain = Pet.from_dict(flask.request.get_json()) # noqa: E501"); + } finally { + FileUtils.deleteDirectory(flaskComposedOutput); + } } } diff --git a/modules/openapi-generator/src/test/resources/3_1/python/composed-query-params.yaml b/modules/openapi-generator/src/test/resources/3_1/python/composed-query-params.yaml new file mode 100644 index 000000000000..568115a63362 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/python/composed-query-params.yaml @@ -0,0 +1,70 @@ +openapi: 3.1.0 + +info: + title: Composed and model query parameter test + version: 1.0.0 + +paths: + /t: + get: + operationId: probe + parameters: + - name: composed + in: query + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Composed' + - name: oneof + in: query + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OneOfM' + - name: addl + in: query + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WithAddl' + - name: plain + in: query + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '200': + description: OK + +components: + schemas: + Pet: + type: object + properties: + name: + type: string + Tag: + type: object + properties: + label: + type: string + Composed: + allOf: + - $ref: '#/components/schemas/Pet' + - $ref: '#/components/schemas/Tag' + OneOfM: + oneOf: + - $ref: '#/components/schemas/Pet' + - $ref: '#/components/schemas/Tag' + WithAddl: + type: object + properties: + a: + type: string + additionalProperties: + type: string From 88f4c75658a4ffd66e65c3d0db3e9b12388b4936 Mon Sep 17 00:00:00 2001 From: khayashi4337 Date: Fri, 23 Oct 2026 12:00:16 +0900 Subject: [PATCH 3/3] 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> --- .../AbstractPythonConnexionServerCodegenTest.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/AbstractPythonConnexionServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/AbstractPythonConnexionServerCodegenTest.java index c4b50ba1bfa2..9d5042c237cd 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/AbstractPythonConnexionServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/AbstractPythonConnexionServerCodegenTest.java @@ -225,10 +225,17 @@ public void testFreeFormAndAnyTypeParametersDoNotCallFromDict() throws IOExcepti List probeParameters = composedOpenAPI.getPaths().get("/t").getGet().getParameters(); CodegenParameter composedParam = composedCodegen.fromParameter(probeParameters.get(0), new HashSet<>()); CodegenParameter oneOfParam = composedCodegen.fromParameter(probeParameters.get(1), new HashSet<>()); + CodegenParameter addlParam = composedCodegen.fromParameter(probeParameters.get(2), new HashSet<>()); assertThat(composedParam.isAnyType).isTrue(); assertThat(composedParam.dataType).isEqualTo("Composed"); assertThat(oneOfParam.isAnyType).isTrue(); assertThat(oneOfParam.dataType).isEqualTo("OneOfM"); + assertThat(addlParam.paramName).isEqualTo("addl"); + assertThat(addlParam.isMap).isTrue(); + assertThat(addlParam.isContainer).isTrue(); + assertThat(addlParam.isFreeFormObject).isFalse(); + assertThat(addlParam.isAnyType).isFalse(); + assertThat(addlParam.dataType).isEqualTo("WithAddl"); File aiohttpOutput = Files.createTempDirectory("test-aiohttp-freeform-params").toFile().getCanonicalFile(); try { @@ -313,9 +320,13 @@ public void testFreeFormAndAnyTypeParametersDoNotCallFromDict() throws IOExcepti assertFileExists(flaskComposedController); assertFileContains( flaskComposedController, + ":type addl: Dict[str, str]", "composed = Composed.from_dict(flask.request.get_json()) # noqa: E501", "oneof = OneOfM.from_dict(flask.request.get_json()) # noqa: E501", "plain = Pet.from_dict(flask.request.get_json()) # noqa: E501"); + assertFileNotContains( + flaskComposedController, + "addl ="); } finally { FileUtils.deleteDirectory(flaskComposedOutput); }