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..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 @@ -281,7 +281,8 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List + * 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]]"; + } + return param.required ? "UploadFile" : "Optional[UploadFile]"; + } + private void addFastAPIUploadFileImport(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(); 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"] ),