Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
OperationMap operations = objs.getOperations();
// Set will make sure that no duplicated items are used.
Set<String> securityImports = new HashSet<>();
boolean hasFileFormParam = false;
if (operations != null) {
List<CodegenOperation> ops = operations.getOperation();
for (final CodegenOperation operation : ops) {
Expand All @@ -254,14 +255,64 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
}

setBodyParamExampleFromContent(operation);
if (overrideFileFormParamTyping(operation)) {
hasFileFormParam = true;
}
}
}

if (hasFileFormParam) {
addFastAPIUploadFileImport(objs);
}

objs.put("securityImports", new ArrayList<>(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<Map<String, String>> imports = objs.getImports();
if (imports == null) {
imports = new ArrayList<>();
objs.setImports(imports);
}
String importLine = "from fastapi import File, UploadFile";
for (Map<String, String> existing : imports) {
if (importLine.equals(existing.get("import"))) {
return;
}
}
Map<String, String> item = new HashMap<>();
item.put("import", importLine);
imports.add(item);
}

private void setBodyParamExampleFromContent(CodegenOperation operation) {
if (operation.bodyParam == null) {
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -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}})
{{#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}})
Original file line number Diff line number Diff line change
Expand Up @@ -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],");
}
}
44 changes: 44 additions & 0 deletions modules/openapi-generator/src/test/resources/bugs/issue_20115.yaml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"]
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
""""""
...
Loading