Skip to content

Commit 179acc9

Browse files
authored
[python-fastapi] type binary multipart fields as UploadFile (#23793)
Multipart binary form fields were generated with the client-side bytes union (Union[StrictBytes, StrictStr, Tuple[StrictStr, StrictBytes]]) and bound via Form(None, ...), which rejected the UploadFile instances FastAPI parses out of multipart bodies and returned HTTP 422. For isFormParam && isFile parameters, override x-py-typing to UploadFile (or Optional[UploadFile] when not required), emit File(...) instead of Form(None, ...) in the route signature, and inject from fastapi import File, UploadFile. Fixes #20115
1 parent 34b8a4d commit 179acc9

6 files changed

Lines changed: 131 additions & 3 deletions

File tree

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
243243
OperationMap operations = objs.getOperations();
244244
// Set will make sure that no duplicated items are used.
245245
Set<String> securityImports = new HashSet<>();
246+
boolean hasFileFormParam = false;
246247
if (operations != null) {
247248
List<CodegenOperation> ops = operations.getOperation();
248249
for (final CodegenOperation operation : ops) {
@@ -254,14 +255,64 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
254255
}
255256

256257
setBodyParamExampleFromContent(operation);
258+
if (overrideFileFormParamTyping(operation)) {
259+
hasFileFormParam = true;
260+
}
257261
}
258262
}
259263

264+
if (hasFileFormParam) {
265+
addFastAPIUploadFileImport(objs);
266+
}
267+
260268
objs.put("securityImports", new ArrayList<>(securityImports));
261269

262270
return objs;
263271
}
264272

273+
/**
274+
* Overrides {@code x-py-typing} for binary multipart form parameters so that they
275+
* are typed as FastAPI {@code UploadFile} instead of the client-side bytes/str union.
276+
* FastAPI parses multipart {@code format: binary} fields into {@link UploadFile} instances;
277+
* the default Pydantic-based union ({@code Union[StrictBytes, StrictStr, ...]}) rejects
278+
* them with a 422 at request time.
279+
*
280+
* @param operation the operation whose parameters may need rewriting
281+
* @return {@code true} if at least one parameter was rewritten
282+
*/
283+
private boolean overrideFileFormParamTyping(CodegenOperation operation) {
284+
boolean changed = false;
285+
for (CodegenParameter param : operation.allParams) {
286+
if (param.isFormParam && param.isFile) {
287+
param.vendorExtensions.put("x-py-typing", param.required ? "UploadFile" : "Optional[UploadFile]");
288+
changed = true;
289+
}
290+
}
291+
for (CodegenParameter param : operation.formParams) {
292+
if (param.isFile) {
293+
param.vendorExtensions.put("x-py-typing", param.required ? "UploadFile" : "Optional[UploadFile]");
294+
}
295+
}
296+
return changed;
297+
}
298+
299+
private void addFastAPIUploadFileImport(OperationsMap objs) {
300+
List<Map<String, String>> imports = objs.getImports();
301+
if (imports == null) {
302+
imports = new ArrayList<>();
303+
objs.setImports(imports);
304+
}
305+
String importLine = "from fastapi import File, UploadFile";
306+
for (Map<String, String> existing : imports) {
307+
if (importLine.equals(existing.get("import"))) {
308+
return;
309+
}
310+
}
311+
Map<String, String> item = new HashMap<>();
312+
item.put("import", importLine);
313+
imports.add(item);
314+
}
315+
265316
private void setBodyParamExampleFromContent(CodegenOperation operation) {
266317
if (operation.bodyParam == null) {
267318
return;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +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}})
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}})

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,35 @@ public void testToPythonExamplePrefersExampleOverExamples() {
105105

106106
Assert.assertEquals(codegen.exposeToPythonExample(cp), "\"doggie\"");
107107
}
108+
109+
@Test(description = "binary multipart form fields are typed as FastAPI UploadFile")
110+
public void testBinaryMultipartFieldUsesUploadFile() throws IOException {
111+
final DefaultCodegen codegen = new PythonFastAPIServerCodegen();
112+
final String outputPath = generateFiles(codegen, "src/test/resources/bugs/issue_20115.yaml");
113+
final Path api = Paths.get(outputPath + "src/openapi_server/apis/default_api.py");
114+
final Path baseApi = Paths.get(outputPath + "src/openapi_server/apis/default_api_base.py");
115+
116+
assertFileExists(api);
117+
assertFileExists(baseApi);
118+
119+
// Required binary form field becomes `UploadFile = File(...)`
120+
assertFileContains(api, "csv_file: UploadFile = File(..., description=\"The CSV file to upload\")");
121+
// Optional binary form field becomes `Optional[UploadFile] = File(None, ...)`
122+
assertFileContains(api, "image: Optional[UploadFile] = File(None, description=\"Optional image upload\")");
123+
124+
// Sibling non-binary form fields still use Form()
125+
assertFileContains(api, "collection_name: Annotated[StrictStr, Field(description=\"Name of the collection\")] = Form(None, description=\"Name of the collection\")");
126+
127+
// The legacy client-side bytes union must not appear for the server signature
128+
assertFileNotContains(api, "Union[StrictBytes, StrictStr, Tuple[StrictStr, StrictBytes]]");
129+
assertFileNotContains(baseApi, "Union[StrictBytes, StrictStr, Tuple[StrictStr, StrictBytes]]");
130+
131+
// FastAPI File/UploadFile imports are emitted
132+
assertFileContains(api, "from fastapi import File, UploadFile");
133+
assertFileContains(baseApi, "from fastapi import File, UploadFile");
134+
135+
// Abstract base class uses UploadFile directly (no Annotated wrapper)
136+
assertFileContains(baseApi, "csv_file: UploadFile,");
137+
assertFileContains(baseApi, "image: Optional[UploadFile],");
138+
}
108139
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
openapi: 3.0.1
2+
info:
3+
title: Issue 20115 reproducer
4+
version: 1.0.0
5+
paths:
6+
/upload:
7+
post:
8+
operationId: uploadCsv
9+
requestBody:
10+
required: true
11+
content:
12+
multipart/form-data:
13+
schema:
14+
type: object
15+
properties:
16+
csv_file:
17+
type: string
18+
format: binary
19+
description: The CSV file to upload
20+
collection_name:
21+
type: string
22+
description: Name of the collection
23+
required:
24+
- csv_file
25+
- collection_name
26+
responses:
27+
'200':
28+
description: OK
29+
/upload-optional:
30+
post:
31+
operationId: uploadOptional
32+
requestBody:
33+
content:
34+
multipart/form-data:
35+
schema:
36+
type: object
37+
properties:
38+
image:
39+
type: string
40+
format: binary
41+
description: Optional image upload
42+
responses:
43+
'200':
44+
description: OK

samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from typing_extensions import Annotated
2929
from openapi_server.models.api_response import ApiResponse
3030
from openapi_server.models.pet import Pet
31+
from fastapi import File, UploadFile
3132
from openapi_server.security_api import get_token_petstore_auth, get_token_api_key
3233

3334
router = APIRouter()
@@ -207,7 +208,7 @@ async def delete_pet(
207208
async def upload_file(
208209
petId: Annotated[StrictInt, Field(description="ID of pet to update")] = Path(..., description="ID of pet to update"),
209210
additional_metadata: Annotated[Optional[StrictStr], Field(description="Additional data to pass to server")] = Form(None, description="Additional data to pass to server"),
210-
file: Annotated[Optional[Union[StrictBytes, StrictStr, Tuple[StrictStr, StrictBytes]]], Field(description="file to upload")] = Form(None, description="file to upload"),
211+
file: Optional[UploadFile] = File(None, description="file to upload"),
211212
token_petstore_auth: TokenModel = Security(
212213
get_token_petstore_auth, scopes=["write:pets", "read:pets"]
213214
),

samples/server/petstore/python-fastapi/src/openapi_server/apis/pet_api_base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing_extensions import Annotated
88
from openapi_server.models.api_response import ApiResponse
99
from openapi_server.models.pet import Pet
10+
from fastapi import File, UploadFile
1011
from openapi_server.security_api import get_token_petstore_auth, get_token_api_key
1112

1213
class BasePetApi:
@@ -78,7 +79,7 @@ async def upload_file(
7879
self,
7980
petId: Annotated[StrictInt, Field(description="ID of pet to update")],
8081
additional_metadata: Annotated[Optional[StrictStr], Field(description="Additional data to pass to server")],
81-
file: Annotated[Optional[Union[StrictBytes, StrictStr, Tuple[StrictStr, StrictBytes]]], Field(description="file to upload")],
82+
file: Optional[UploadFile],
8283
) -> ApiResponse:
8384
""""""
8485
...

0 commit comments

Comments
 (0)