Skip to content
Merged
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 @@ -281,7 +281,8 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
* 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.
* them with a 422 at request time. Array properties with {@code items.format: binary} use
* {@code List[UploadFile]} so multiple parts with the same field name bind correctly.
*
* @param operation the operation whose parameters may need rewriting
* @return {@code true} if at least one parameter was rewritten
Expand All @@ -290,18 +291,36 @@ 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]");
param.vendorExtensions.put(X_PY_TYPING, uploadFileFormParamTyping(param));
changed = true;
}
}
for (CodegenParameter param : operation.formParams) {
if (param.isFile) {
param.vendorExtensions.put(X_PY_TYPING, param.required ? "UploadFile" : "Optional[UploadFile]");
param.vendorExtensions.put(X_PY_TYPING, uploadFileFormParamTyping(param));
}
}
return changed;
}

/**
* Returns the FastAPI type string for a binary multipart form parameter to store in
* {@code x-py-typing}.
* <p>
* 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<Map<String, String>> imports = objs.getImports();
if (imports == null) {
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}}{{#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}})
{{#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}})
Original file line number Diff line number Diff line change
Expand Up @@ -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]]");
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
),
Expand Down Expand Up @@ -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"]
),
Expand Down
Loading