mirror of
https://github.com/fastapi/fastapi.git
synced 2026-10-30 05:41:02 -04:00
🐛 Fix AliasChoices extraction for form models (#16557)
This commit is contained in:
1 parent
e08d28df25
commit
75f3a14935
4 files changed
+141
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user