From cdf10633fddd3ea43c520ac070ba36beb490d721 Mon Sep 17 00:00:00 2001 From: Jerry Date: Tue, 21 Jul 2026 23:39:05 +0800 Subject: [PATCH 1/3] fix(python-fastapi): multipart array files and Form/File wire-name aliases Type multipart array-of-binary fields as List[UploadFile] instead of a single UploadFile so FastAPI binds multiple parts with the same field name. Emit alias="{{baseName}}" on Form(...) and File(...) like Query(...), keeping snake_case Python parameters while matching OpenAPI wire names (e.g. additionalMetadata, statusArray). Add regression tests in PythonFastAPIServerCodegenTest using form-multipart-binary-array.yaml and petstore.yaml. --- .../languages/PythonFastAPIServerCodegen.java | 14 ++++-- .../endpoint_argument_definition.mustache | 2 +- .../PythonFastAPIServerCodegenTest.java | 44 +++++++++++++++++-- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java index 91e37991b8c0..7587f3036bcf 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java @@ -281,7 +281,8 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List> imports = objs.getImports(); if (imports == null) { diff --git a/modules/openapi-generator/src/main/resources/python-fastapi/endpoint_argument_definition.mustache b/modules/openapi-generator/src/main/resources/python-fastapi/endpoint_argument_definition.mustache index 9925098fbb65..e1a76b210fd7 100644 --- a/modules/openapi-generator/src/main/resources/python-fastapi/endpoint_argument_definition.mustache +++ b/modules/openapi-generator/src/main/resources/python-fastapi/endpoint_argument_definition.mustache @@ -1 +1 @@ -{{#isPathParam}}{{baseName}}{{/isPathParam}}{{^isPathParam}}{{paramName}}{{/isPathParam}}: {{>param_type}} = {{#isPathParam}}Path{{/isPathParam}}{{#isHeaderParam}}Header{{/isHeaderParam}}{{#isFormParam}}{{#isFile}}File{{/isFile}}{{^isFile}}Form{{/isFile}}{{/isFormParam}}{{#isQueryParam}}Query{{/isQueryParam}}{{#isCookieParam}}Cookie{{/isCookieParam}}{{#isBodyParam}}Body{{/isBodyParam}}({{&defaultValue}}{{^defaultValue}}{{#isPathParam}}...{{/isPathParam}}{{^isPathParam}}{{#isFile}}{{#required}}...{{/required}}{{^required}}None{{/required}}{{/isFile}}{{^isFile}}None{{/isFile}}{{/isPathParam}}{{/defaultValue}}, description="{{description}}"{{#isQueryParam}}, alias="{{baseName}}"{{/isQueryParam}}{{#isLong}}{{#minimum}}, ge={{.}}{{/minimum}}{{#maximum}}, le={{.}}{{/maximum}}{{/isLong}}{{#isInteger}}{{#minimum}}, ge={{.}}{{/minimum}}{{#maximum}}, le={{.}}{{/maximum}}{{/isInteger}}{{#vendorExtensions.x-regex}}, regex=r"{{.}}"{{/vendorExtensions.x-regex}}{{#minLength}}, min_length={{.}}{{/minLength}}{{#maxLength}}, max_length={{.}}{{/maxLength}}{{^isBodyParam}}{{#vendorExtensions.x-py-example}}, examples=[{{{.}}}]{{/vendorExtensions.x-py-example}}{{/isBodyParam}}{{#isBodyParam}}{{#vendorExtensions.x-py-fastapi-example}}, examples=[{{{.}}}]{{/vendorExtensions.x-py-fastapi-example}}{{/isBodyParam}}) \ No newline at end of file +{{#isPathParam}}{{baseName}}{{/isPathParam}}{{^isPathParam}}{{paramName}}{{/isPathParam}}: {{>param_type}} = {{#isPathParam}}Path{{/isPathParam}}{{#isHeaderParam}}Header{{/isHeaderParam}}{{#isFormParam}}{{#isFile}}File{{/isFile}}{{^isFile}}Form{{/isFile}}{{/isFormParam}}{{#isQueryParam}}Query{{/isQueryParam}}{{#isCookieParam}}Cookie{{/isCookieParam}}{{#isBodyParam}}Body{{/isBodyParam}}({{&defaultValue}}{{^defaultValue}}{{#isPathParam}}...{{/isPathParam}}{{^isPathParam}}{{#isFile}}{{#required}}...{{/required}}{{^required}}None{{/required}}{{/isFile}}{{^isFile}}None{{/isFile}}{{/isPathParam}}{{/defaultValue}}, description="{{description}}"{{#isQueryParam}}, alias="{{baseName}}"{{/isQueryParam}}{{#isFormParam}}, alias="{{baseName}}"{{/isFormParam}}{{#isLong}}{{#minimum}}, ge={{.}}{{/minimum}}{{#maximum}}, le={{.}}{{/maximum}}{{/isLong}}{{#isInteger}}{{#minimum}}, ge={{.}}{{/minimum}}{{#maximum}}, le={{.}}{{/maximum}}{{/isInteger}}{{#vendorExtensions.x-regex}}, regex=r"{{.}}"{{/vendorExtensions.x-regex}}{{#minLength}}, min_length={{.}}{{/minLength}}{{#maxLength}}, max_length={{.}}{{/maxLength}}{{^isBodyParam}}{{#vendorExtensions.x-py-example}}, examples=[{{{.}}}]{{/vendorExtensions.x-py-example}}{{/isBodyParam}}{{#isBodyParam}}{{#vendorExtensions.x-py-fastapi-example}}, examples=[{{{.}}}]{{/vendorExtensions.x-py-fastapi-example}}{{/isBodyParam}}) \ No newline at end of file diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFastAPIServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFastAPIServerCodegenTest.java index ea70acdb67da..34d19499cec7 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFastAPIServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/python/PythonFastAPIServerCodegenTest.java @@ -117,12 +117,12 @@ public void testBinaryMultipartFieldUsesUploadFile() throws IOException { assertFileExists(baseApi); // Required binary form field becomes `UploadFile = File(...)` - assertFileContains(api, "csv_file: UploadFile = File(..., description=\"The CSV file to upload\")"); + assertFileContains(api, "csv_file: UploadFile = File(..., description=\"The CSV file to upload\", alias=\"csv_file\")"); // Optional binary form field becomes `Optional[UploadFile] = File(None, ...)` - assertFileContains(api, "image: Optional[UploadFile] = File(None, description=\"Optional image upload\")"); + assertFileContains(api, "image: Optional[UploadFile] = File(None, description=\"Optional image upload\", alias=\"image\")"); // Sibling non-binary form fields still use Form() - assertFileContains(api, "collection_name: Annotated[StrictStr, Field(description=\"Name of the collection\")] = Form(None, description=\"Name of the collection\")"); + assertFileContains(api, "collection_name: Annotated[StrictStr, Field(description=\"Name of the collection\")] = Form(None, description=\"Name of the collection\", alias=\"collection_name\")"); // The legacy client-side bytes union must not appear for the server signature assertFileNotContains(api, "Union[StrictBytes, StrictStr, Tuple[StrictStr, StrictBytes]]"); @@ -137,6 +137,44 @@ public void testBinaryMultipartFieldUsesUploadFile() throws IOException { assertFileContains(baseApi, "image: Optional[UploadFile],"); } + @Test(description = "multipart array of binary form fields are typed as List[UploadFile]") + public void testMultipartArrayOfBinaryUsesListUploadFile() throws IOException { + final DefaultCodegen codegen = new PythonFastAPIServerCodegen(); + final String outputPath = generateFiles(codegen, "src/test/resources/3_0/form-multipart-binary-array.yaml"); + final Path api = Paths.get(outputPath + "src/openapi_server/apis/multipart_api.py"); + final Path baseApi = Paths.get(outputPath + "src/openapi_server/apis/multipart_api_base.py"); + + assertFileExists(api); + assertFileExists(baseApi); + + assertFileContains(api, "files: Optional[List[UploadFile]] = File(None, description=\"Many files\", alias=\"files\")"); + assertFileContains(baseApi, "files: Optional[List[UploadFile]],"); + + assertFileContains(api, "file: Optional[UploadFile] = File(None, description=\"One file\", alias=\"file\")"); + assertFileContains(baseApi, "file: Optional[UploadFile],"); + + assertFileNotContains(api, "files: Optional[UploadFile] = File(None, description=\"Many files\")"); + assertFileNotContains(baseApi, "files: Optional[UploadFile],"); + } + + @Test(description = "multipart Form/File use OpenAPI wire names via alias (#17111 parity for form fields)") + public void testMultipartFormFieldsUseWireNameAlias() throws IOException { + final DefaultCodegen codegen = new PythonFastAPIServerCodegen(); + final String multipartPath = generateFiles(codegen, "src/test/resources/3_0/form-multipart-binary-array.yaml"); + final Path multipartApi = Paths.get(multipartPath + "src/openapi_server/apis/multipart_api.py"); + + assertFileExists(multipartApi); + assertFileContains(multipartApi, "status_array: Optional[List[MultipartMixedStatus]] = Form(None, description=\"\", alias=\"statusArray\")"); + + final DefaultCodegen petstoreCodegen = new PythonFastAPIServerCodegen(); + final String petstorePath = generateFiles(petstoreCodegen, "src/test/resources/3_0/python-fastapi/petstore.yaml"); + final Path petApi = Paths.get(petstorePath + "src/openapi_server/apis/pet_api.py"); + + assertFileExists(petApi); + assertFileContains(petApi, "additional_metadata: Annotated[Optional[StrictStr], Field(description=\"Additional data to pass to server\")] = Form(None, description=\"Additional data to pass to server\", alias=\"additionalMetadata\")"); + assertFileContains(petApi, "file: Optional[UploadFile] = File(None, description=\"file to upload\", alias=\"file\")"); + } + @Test(description = "binary response body is typed as bytes, not invalid file (#20775)") public void testBinaryResponseUsesBytesNotFile() throws IOException { final DefaultCodegen codegen = new PythonFastAPIServerCodegen(); From 7ddadd059380b68788711ae5cbb8fc7845d126c6 Mon Sep 17 00:00:00 2001 From: Jerry Date: Tue, 21 Jul 2026 23:47:51 +0800 Subject: [PATCH 2/3] update sample --- .../python-fastapi/src/openapi_server/apis/pet_api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py index 660a8d99d437..7e9fe5def812 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py @@ -162,8 +162,8 @@ async def get_pet_by_id( ) async def update_pet_with_form( petId: Annotated[StrictInt, Field(description="ID of pet that needs to be updated")] = Path(..., description="ID of pet that needs to be updated"), - name: Annotated[Optional[StrictStr], Field(description="Updated name of the pet")] = Form(None, description="Updated name of the pet"), - status: Annotated[Optional[StrictStr], Field(description="Updated status of the pet")] = Form(None, description="Updated status of the pet"), + name: Annotated[Optional[StrictStr], Field(description="Updated name of the pet")] = Form(None, description="Updated name of the pet", alias="name"), + status: Annotated[Optional[StrictStr], Field(description="Updated status of the pet")] = Form(None, description="Updated status of the pet", alias="status"), token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["write:pets", "read:pets"] ), @@ -207,8 +207,8 @@ async def delete_pet( ) async def upload_file( petId: Annotated[StrictInt, Field(description="ID of pet to update")] = Path(..., description="ID of pet to update"), - additional_metadata: Annotated[Optional[StrictStr], Field(description="Additional data to pass to server")] = Form(None, description="Additional data to pass to server"), - file: Optional[UploadFile] = File(None, description="file to upload"), + additional_metadata: Annotated[Optional[StrictStr], Field(description="Additional data to pass to server")] = Form(None, description="Additional data to pass to server", alias="additionalMetadata"), + file: Optional[UploadFile] = File(None, description="file to upload", alias="file"), token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["write:pets", "read:pets"] ), From 6635d72ed6ff008dccfce1d5b30fb1aa1be6f9e0 Mon Sep 17 00:00:00 2001 From: Jerry Date: Wed, 22 Jul 2026 19:09:40 +0800 Subject: [PATCH 3/3] Add Javadoc for the multipart UploadFile typing helper --- .../languages/PythonFastAPIServerCodegen.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java index 7587f3036bcf..ff1205951131 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PythonFastAPIServerCodegen.java @@ -303,7 +303,18 @@ private boolean overrideFileFormParamTyping(CodegenOperation operation) { return changed; } - private static String uploadFileFormParamTyping(CodegenParameter param) { + /** + * Returns the FastAPI type string for a binary multipart form parameter to store in + * {@code x-py-typing}. + *

+ * A single {@code format: binary} field becomes {@code UploadFile} or {@code Optional[UploadFile]}. + * An array of binary items becomes {@code List[UploadFile]} or {@code Optional[List[UploadFile]]} + * so multiple parts sharing the same field name bind correctly. + * + * @param param the form parameter being typed + * @return Python typing for the generated endpoint signature + */ + private String uploadFileFormParamTyping(CodegenParameter param) { if (param.isArray && param.isFile) { return param.required ? "List[UploadFile]" : "Optional[List[UploadFile]]"; }