diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index f5f7fe9ef3..255f975805 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -1013,27 +1013,19 @@ def _extract_form_items( if isinstance(validation_alias, AliasChoices): aliases = list(cast(list[str], validation_alias.choices)) config = field.config or {} - if config.get("validate_by_name", config.get("populate_by_name", False)): - if config.get("validate_by_alias") is False: - aliases = [field.name] - elif field.name not in aliases: - aliases.append(field.name) + if config.get("validate_by_alias") is False: + aliases = [field.name] + elif ( + config.get("validate_by_name", config.get("populate_by_name", False)) + and field.name not in aliases + ): + aliases.append(field.name) # Interpret missing boolean form fields as unchecked HTML checkboxes. infer_false_if_missing = ( not has_alias_path and field.field_info.annotation in (bool, bool | None) and not any(alias in received_body for alias in aliases) ) - if field.config is None or infer_false_if_missing: - selected_alias = aliases[0] - for alias in aliases: - if ( - _get_multidict_value(field, received_body, alias=alias) - is not Undefined - ): - selected_alias = alias - break - aliases = [selected_alias] for alias in aliases: field_aliases.add(alias) if infer_false_if_missing: @@ -1046,6 +1038,8 @@ def _extract_form_items( field_aliases.add(alias) if value is not Undefined: yield alias, value, field + # Leave later aliases unchanged for other fields or extra validation. + break for key in received_body.keys(): if key not in field_aliases: param_values = received_body.getlist(key) diff --git a/tests/test_request_params/test_file/test_nullable_and_defaults.py b/tests/test_request_params/test_file/test_nullable_and_defaults.py index 4fc0b85aaf..c2950f8a50 100644 --- a/tests/test_request_params/test_file/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_file/test_nullable_and_defaults.py @@ -318,6 +318,37 @@ def test_model_bytes_files_use_validation_aliases(file_alias, files_alias): assert response.json() == {"file": "one", "files": ["two", "three"]} +def test_model_files_can_share_an_unused_alias_choice(): + app = FastAPI() + + class FileModel(BaseModel): + files: Annotated[ + list[UploadFile], Field(validation_alias=AliasChoices("files", "legacy")) + ] + file: Annotated[ + UploadFile, Field(validation_alias=AliasChoices("file", "legacy")) + ] + + @app.post("/") + def read_form(data: Annotated[FileModel, Form()]): + return { + "files": [file.filename for file in data.files], + "file": data.file.filename, + } + + client = TestClient(app) + response = client.post( + "/", + files=[ + ("legacy", ("one.txt", b"one")), + ("legacy", ("two.txt", b"two")), + ("file", ("hello.txt", b"hello")), + ], + ) + assert response.status_code == 200 + assert response.json() == {"files": ["one.txt", "two.txt"], "file": "hello.txt"} + + def test_openapi_schema(): response = client.get("/openapi.json") assert response.status_code == 200, response.text diff --git a/tests/test_request_params/test_form/test_nullable_and_defaults.py b/tests/test_request_params/test_form/test_nullable_and_defaults.py index 38427b61ad..0c45682f2b 100644 --- a/tests/test_request_params/test_form/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_form/test_nullable_and_defaults.py @@ -228,10 +228,10 @@ def test_empty_form_model_value_uses_alias(field): [ ConfigDict(populate_by_name=True, extra="forbid"), pytest.param( - ConfigDict(validate_by_name=True, validate_by_alias=False, extra="forbid"), + ConfigDict(validate_by_alias=False, extra="forbid"), marks=pytest.mark.skipif( PYDANTIC_VERSION_MINOR_TUPLE < (2, 11), - reason="validate_by_name and validate_by_alias require Pydantic 2.11", + reason="validate_by_alias requires Pydantic 2.11", ), ), ], @@ -396,6 +396,86 @@ def test_form_list_alias_choices_preserve_empty_items(path, data, expected): assert response.json() == {"value": expected} +@pytest.mark.parametrize("encoding", ["urlencoded", "multipart"]) +def test_form_model_fields_can_share_an_unused_alias_choice(encoding): + app = FastAPI() + + class Model(BaseModel): + names: Annotated[ + list[str], Field(validation_alias=AliasChoices("names", "legacy")) + ] + text: Annotated[str, Field(validation_alias=AliasChoices("text", "legacy"))] + + @app.post("/") + def read_model(params: Annotated[Model, Form()]): + return params + + client = TestClient(app) + if encoding == "urlencoded": + response = client.post("/", data={"legacy": ["one", "two"], "text": "hello"}) + else: + response = client.post( + "/", + files=[ + ("legacy", (None, "one")), + ("legacy", (None, "two")), + ("text", (None, "hello")), + ], + ) + assert response.status_code == 200 + assert response.json() == {"names": ["one", "two"], "text": "hello"} + + +@pytest.mark.parametrize("encoding", ["urlencoded", "multipart"]) +@pytest.mark.parametrize("unused", ["unused", ""]) +def test_unused_form_alias_choice_keeps_extra_value_shape(encoding, unused): + app = FastAPI() + + class Model(BaseModel): + model_config = ConfigDict(extra="allow") + names: Annotated[ + list[str], Field(validation_alias=AliasChoices("names", "legacy-names")) + ] + text: Annotated[ + str, Field(validation_alias=AliasChoices("text", "legacy-text")) + ] + + @app.post("/") + def read_model(params: Annotated[Model, Form()]): + return params + + client = TestClient(app) + if encoding == "urlencoded": + response = client.post( + "/", + data={ + "names": ["one", "two"], + "text": "hello", + "legacy-names": unused, + "legacy-text": ["", "unused"], + }, + ) + else: + response = client.post( + "/", + files=[ + ("names", (None, "one")), + ("names", (None, "two")), + ("text", (None, "hello")), + ("legacy-names", (None, unused)), + ("legacy-text", (None, "")), + ("legacy-text", (None, "unused")), + ], + ) + assert response.status_code == 200 + assert response.json() == { + "names": ["one", "two"], + "text": "hello", + "legacy-names": unused, + "legacy-text": ["", "unused"], + } + + def test_empty_form_model_value_preserves_extra_and_json_strings(): app = FastAPI() @@ -749,10 +829,10 @@ def test_checkbox_uses_alias(path, alias, validation_alias, data, expected): [ ConfigDict(populate_by_name=True, extra="forbid"), pytest.param( - ConfigDict(validate_by_name=True, validate_by_alias=False, extra="forbid"), + ConfigDict(validate_by_alias=False, extra="forbid"), marks=pytest.mark.skipif( PYDANTIC_VERSION_MINOR_TUPLE < (2, 11), - reason="validate_by_name and validate_by_alias require Pydantic 2.11", + reason="validate_by_alias requires Pydantic 2.11", ), ), ], @@ -820,11 +900,24 @@ def test_checkbox_with_alias_choices(path, data, expected): assert response.json() == {"value": expected} -def test_unchecked_checkbox_with_alias_choices_overrides_defaults(): +@pytest.mark.parametrize( + "config", + [ + ConfigDict(extra="forbid"), + pytest.param( + ConfigDict(validate_by_alias=False, extra="forbid"), + marks=pytest.mark.skipif( + PYDANTIC_VERSION_MINOR_TUPLE < (2, 11), + reason="validate_by_alias requires Pydantic 2.11", + ), + ), + ], +) +def test_unchecked_checkbox_with_alias_choices_overrides_defaults(config): app = FastAPI() class Model(BaseModel): - model_config = ConfigDict(extra="forbid") + model_config = config value: Annotated[ bool, Field(validation_alias=AliasChoices("enabled", "checkbox")) ] = True diff --git a/tests/test_union_form_normalization.py b/tests/test_union_form_normalization.py index 22d470f962..4d348b24a3 100644 --- a/tests/test_union_form_normalization.py +++ b/tests/test_union_form_normalization.py @@ -251,12 +251,12 @@ def test_alias_choices_and_forbidden_extras(form): assert response.status_code == 200 assert response.json() == {"kind": "aliased", "value": "hello", "enabled": False} response = client.post( - "/", data={"kind": "aliased", "primary": "hello", "extra": ""} + "/", data={"kind": "aliased", "primary": "hello", "secondary": ""} ) assert response.status_code == 422 errors = response.json()["detail"] assert any( - error["type"] == "extra_forbidden" and error["loc"][-1] == "extra" + error["type"] == "extra_forbidden" and error["loc"][-1] == "secondary" for error in errors )