🐛 Fix AliasChoices extraction for form models (#16557)

This commit is contained in:
Sebastián Ramírez authored and GitHub committed 2026-10-20 22:19:33 +00:00
1 parent e08d28df25
commit 75f3a14935
4 files changed
+141 -23

No files matched your search

+9 -15
View File
@@ -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)
@@ -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
@@ -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
+2 -2
View File
@@ -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
)