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 b820960915d1..ea617b75581b 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 @@ -243,6 +243,7 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List securityImports = new HashSet<>(); + boolean hasFileFormParam = false; if (operations != null) { List ops = operations.getOperation(); for (final CodegenOperation operation : ops) { @@ -254,14 +255,64 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List(securityImports)); return objs; } + /** + * Overrides {@code x-py-typing} for binary multipart form parameters so that they + * are typed as FastAPI {@code UploadFile} instead of the client-side bytes/str union. + * FastAPI parses multipart {@code format: binary} fields into {@link UploadFile} instances; + * the default Pydantic-based union ({@code Union[StrictBytes, StrictStr, ...]}) rejects + * them with a 422 at request time. + * + * @param operation the operation whose parameters may need rewriting + * @return {@code true} if at least one parameter was rewritten + */ + private boolean overrideFileFormParamTyping(CodegenOperation operation) { + boolean changed = false; + for (CodegenParameter param : operation.allParams) { + if (param.isFormParam && param.isFile) { + param.vendorExtensions.put("x-py-typing", param.required ? "UploadFile" : "Optional[UploadFile]"); + changed = true; + } + } + for (CodegenParameter param : operation.formParams) { + if (param.isFile) { + param.vendorExtensions.put("x-py-typing", param.required ? "UploadFile" : "Optional[UploadFile]"); + } + } + return changed; + } + + private void addFastAPIUploadFileImport(OperationsMap objs) { + List> imports = objs.getImports(); + if (imports == null) { + imports = new ArrayList<>(); + objs.setImports(imports); + } + String importLine = "from fastapi import File, UploadFile"; + for (Map existing : imports) { + if (importLine.equals(existing.get("import"))) { + return; + } + } + Map item = new HashMap<>(); + item.put("import", importLine); + imports.add(item); + } + private void setBodyParamExampleFromContent(CodegenOperation operation) { if (operation.bodyParam == null) { return; 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 24e6da7bb28e..9925098fbb65 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}}Form{{/isFormParam}}{{#isQueryParam}}Query{{/isQueryParam}}{{#isCookieParam}}Cookie{{/isCookieParam}}{{#isBodyParam}}Body{{/isBodyParam}}({{&defaultValue}}{{^defaultValue}}{{#isPathParam}}...{{/isPathParam}}{{^isPathParam}}None{{/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}}{{#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 7cce9fc18705..ff381bacecbd 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 @@ -105,4 +105,35 @@ public void testToPythonExamplePrefersExampleOverExamples() { Assert.assertEquals(codegen.exposeToPythonExample(cp), "\"doggie\""); } + + @Test(description = "binary multipart form fields are typed as FastAPI UploadFile") + public void testBinaryMultipartFieldUsesUploadFile() throws IOException { + final DefaultCodegen codegen = new PythonFastAPIServerCodegen(); + final String outputPath = generateFiles(codegen, "src/test/resources/bugs/issue_20115.yaml"); + final Path api = Paths.get(outputPath + "src/openapi_server/apis/default_api.py"); + final Path baseApi = Paths.get(outputPath + "src/openapi_server/apis/default_api_base.py"); + + assertFileExists(api); + assertFileExists(baseApi); + + // Required binary form field becomes `UploadFile = File(...)` + assertFileContains(api, "csv_file: UploadFile = File(..., description=\"The CSV file to upload\")"); + // Optional binary form field becomes `Optional[UploadFile] = File(None, ...)` + assertFileContains(api, "image: Optional[UploadFile] = File(None, description=\"Optional image upload\")"); + + // 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\")"); + + // The legacy client-side bytes union must not appear for the server signature + assertFileNotContains(api, "Union[StrictBytes, StrictStr, Tuple[StrictStr, StrictBytes]]"); + assertFileNotContains(baseApi, "Union[StrictBytes, StrictStr, Tuple[StrictStr, StrictBytes]]"); + + // FastAPI File/UploadFile imports are emitted + assertFileContains(api, "from fastapi import File, UploadFile"); + assertFileContains(baseApi, "from fastapi import File, UploadFile"); + + // Abstract base class uses UploadFile directly (no Annotated wrapper) + assertFileContains(baseApi, "csv_file: UploadFile,"); + assertFileContains(baseApi, "image: Optional[UploadFile],"); + } } diff --git a/modules/openapi-generator/src/test/resources/bugs/issue_20115.yaml b/modules/openapi-generator/src/test/resources/bugs/issue_20115.yaml new file mode 100644 index 000000000000..7f40d15cfa59 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/bugs/issue_20115.yaml @@ -0,0 +1,44 @@ +openapi: 3.0.1 +info: + title: Issue 20115 reproducer + version: 1.0.0 +paths: + /upload: + post: + operationId: uploadCsv + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + csv_file: + type: string + format: binary + description: The CSV file to upload + collection_name: + type: string + description: Name of the collection + required: + - csv_file + - collection_name + responses: + '200': + description: OK + /upload-optional: + post: + operationId: uploadOptional + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + image: + type: string + format: binary + description: Optional image upload + responses: + '200': + description: OK 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 416272bcd61d..660a8d99d437 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 @@ -28,6 +28,7 @@ from typing_extensions import Annotated from openapi_server.models.api_response import ApiResponse from openapi_server.models.pet import Pet +from fastapi import File, UploadFile from openapi_server.security_api import get_token_petstore_auth, get_token_api_key router = APIRouter() @@ -207,7 +208,7 @@ 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: Annotated[Optional[Union[StrictBytes, StrictStr, Tuple[StrictStr, StrictBytes]]], Field(description="file to upload")] = Form(None, description="file to upload"), + file: Optional[UploadFile] = File(None, description="file to upload"), token_petstore_auth: TokenModel = Security( get_token_petstore_auth, scopes=["write:pets", "read:pets"] ), diff --git a/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api_base.py b/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api_base.py index 0d1fb77e442d..4f8b292e3eec 100644 --- a/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api_base.py +++ b/samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api_base.py @@ -7,6 +7,7 @@ from typing_extensions import Annotated from openapi_server.models.api_response import ApiResponse from openapi_server.models.pet import Pet +from fastapi import File, UploadFile from openapi_server.security_api import get_token_petstore_auth, get_token_api_key class BasePetApi: @@ -78,7 +79,7 @@ async def upload_file( self, petId: Annotated[StrictInt, Field(description="ID of pet to update")], additional_metadata: Annotated[Optional[StrictStr], Field(description="Additional data to pass to server")], - file: Annotated[Optional[Union[StrictBytes, StrictStr, Tuple[StrictStr, StrictBytes]]], Field(description="file to upload")], + file: Optional[UploadFile], ) -> ApiResponse: """""" ...