mirror of https://github.com/tiangolo/fastapi.git
Merge 292c1fdeca into 272204c0c7
This commit is contained in:
commit
9a2eab07a0
|
|
@ -750,24 +750,31 @@ def _validate_value_with_model_field(
|
|||
|
||||
|
||||
def _get_multidict_value(
|
||||
field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None
|
||||
field: ModelField,
|
||||
values: Mapping[str, Any],
|
||||
alias: Union[str, None] = None,
|
||||
form_input: bool = False,
|
||||
) -> Any:
|
||||
alias = alias or get_validation_alias(field)
|
||||
if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)):
|
||||
value = values.getlist(alias)
|
||||
else:
|
||||
value = values.get(alias, None)
|
||||
if (
|
||||
value is None
|
||||
or (
|
||||
isinstance(field.field_info, (params.Form, temp_pydantic_v1_params.Form))
|
||||
and isinstance(value, str) # For type checks
|
||||
and value == ""
|
||||
)
|
||||
or (is_sequence_field(field) and len(value) == 0)
|
||||
|
||||
if form_input:
|
||||
# Special handling for form inputs:
|
||||
# Treat empty strings or empty lists as missing values
|
||||
if (isinstance(value, str) and value == "") or (
|
||||
is_sequence_field(field) and len(value) == 0
|
||||
):
|
||||
return None
|
||||
else:
|
||||
# For non-form inputs:
|
||||
# If value is None or an empty sequence, use the default (if not required) or
|
||||
# treat as missing (if required)
|
||||
if value is None or (is_sequence_field(field) and len(value) == 0):
|
||||
if field.required:
|
||||
return
|
||||
return None
|
||||
else:
|
||||
return deepcopy(field.default)
|
||||
return value
|
||||
|
|
@ -906,7 +913,7 @@ async def _extract_form_body(
|
|||
values = {}
|
||||
|
||||
for field in body_fields:
|
||||
value = _get_multidict_value(field, received_body)
|
||||
value = _get_multidict_value(field, received_body, form_input=True)
|
||||
field_info = field.field_info
|
||||
if (
|
||||
isinstance(field_info, (params.File, temp_pydantic_v1_params.File))
|
||||
|
|
|
|||
|
|
@ -1,12 +1,73 @@
|
|||
from typing import Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import FastAPI, File, Form
|
||||
from starlette.testclient import TestClient
|
||||
import pytest
|
||||
from fastapi import FastAPI, File, Form, UploadFile
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import Annotated
|
||||
|
||||
from .utils import needs_pydanticv2
|
||||
|
||||
|
||||
class FormParams(BaseModel):
|
||||
param_1: str = "default"
|
||||
param_2: Annotated[str, Field(alias="param_2_alias")] = "default"
|
||||
param_3: Annotated[str, Form(alias="param_3_alias")] = "default"
|
||||
upload_file: Optional[UploadFile] = None
|
||||
file: Annotated[Optional[bytes], File()] = None
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.post("/form-param")
|
||||
def form_param(
|
||||
param_1: Annotated[str, Form()] = "default",
|
||||
param_2: Annotated[str, Form(alias="param_2_alias")] = "default",
|
||||
upload_file: Optional[UploadFile] = None,
|
||||
file: Annotated[Optional[bytes], File()] = None,
|
||||
):
|
||||
return {
|
||||
"param_1": param_1,
|
||||
"param_2": param_2,
|
||||
"upload_file": upload_file,
|
||||
"file": file,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/form-model")
|
||||
def form_model(params: Annotated[FormParams, Form()]):
|
||||
return {
|
||||
"param_1": params.param_1,
|
||||
"param_2": params.param_2,
|
||||
"param_3": params.param_3,
|
||||
"upload_file": params.upload_file,
|
||||
"file": params.file,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/form-param-list-with-default")
|
||||
def form_param_list_with_default(
|
||||
params: Annotated[
|
||||
List[str],
|
||||
Form(),
|
||||
] = [], # noqa: B006 (default_factory doesn't work with Pydantic V1)
|
||||
):
|
||||
return {"params": params}
|
||||
|
||||
|
||||
@app.post("/form-param-optional-list")
|
||||
def form_param_optional_list(
|
||||
params: Annotated[Optional[List[str]], Form()] = None,
|
||||
):
|
||||
return {"params": params}
|
||||
|
||||
|
||||
@app.post("/form-model-fields-set")
|
||||
def form_model_fields_set(params: Annotated[FormParams, Form()]):
|
||||
return {"fields_set": params.model_fields_set}
|
||||
|
||||
|
||||
@app.post("/urlencoded")
|
||||
async def post_url_encoded(age: Annotated[Optional[int], Form()] = None):
|
||||
return age
|
||||
|
|
@ -20,16 +81,137 @@ async def post_multi_part(
|
|||
return {"file": file, "age": age}
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def test_form_default_url_encoded():
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
pytest.param({}, id="no_data_sent"),
|
||||
pytest.param(
|
||||
{"param_1": "", "param_2_alias": "", "upload_file": "", "file": ""},
|
||||
id="empty_strings_sent",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_defaults_form_param(client: TestClient, data: dict):
|
||||
"""
|
||||
Empty string or no input data - default value is used.
|
||||
For parameters declared as single Form fields.
|
||||
"""
|
||||
response = client.post("/form-param", data=data)
|
||||
assert response.json() == {
|
||||
"param_1": "default",
|
||||
"param_2": "default",
|
||||
"upload_file": None,
|
||||
"file": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
pytest.param({}, id="no_data_sent"),
|
||||
pytest.param(
|
||||
{
|
||||
"param_1": "",
|
||||
"param_2_alias": "",
|
||||
"param_3_alias": "",
|
||||
"upload_file": "",
|
||||
"file": "",
|
||||
},
|
||||
id="empty_strings_sent",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_defaults_form_model(client: TestClient, data: dict):
|
||||
"""
|
||||
Empty string or no data - default value is used.
|
||||
For parameters declared as Form model.
|
||||
"""
|
||||
response = client.post("/form-model", data=data)
|
||||
assert response.json() == {
|
||||
"param_1": "default",
|
||||
"param_2": "default",
|
||||
"param_3": "default",
|
||||
"upload_file": None,
|
||||
"file": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "expected_res"),
|
||||
[
|
||||
("/form-param-list-with-default", []),
|
||||
("/form-param-optional-list", None),
|
||||
],
|
||||
)
|
||||
def test_form_list_param_no_input(client: TestClient, url: str, expected_res: Any):
|
||||
"""
|
||||
No input data passed to list parameter - default value is used.
|
||||
"""
|
||||
response = client.post(url)
|
||||
assert response.json() == {"params": expected_res}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("data", "expected_res"),
|
||||
[
|
||||
({"params": ""}, [""]),
|
||||
({"params": [""]}, [""]),
|
||||
({"params": ["", "a"]}, ["", "a"]),
|
||||
({"params": ["a", ""]}, ["a", ""]),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"url", ["/form-param-list-with-default", "/form-param-optional-list"]
|
||||
)
|
||||
def test_form_list_param_empty_str(
|
||||
client: TestClient, url: str, data: dict, expected_res: Any
|
||||
):
|
||||
"""
|
||||
Empty strings passed to list parameter treated as empty strings.
|
||||
"""
|
||||
response = client.post(url, data=data)
|
||||
assert response.json() == {"params": expected_res}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
pytest.param({}, id="no_data_sent"),
|
||||
pytest.param(
|
||||
{
|
||||
"param_1": "",
|
||||
"param_2_alias": "",
|
||||
"param_3_alias": "",
|
||||
"upload_file": "",
|
||||
"file": "",
|
||||
},
|
||||
id="empty_strings_sent",
|
||||
),
|
||||
],
|
||||
)
|
||||
@needs_pydanticv2
|
||||
def test_form_model_fields_set(client: TestClient, data: dict):
|
||||
"""
|
||||
Check that fields are not pre-populated with default values when no data sent or
|
||||
empty string sent.
|
||||
"""
|
||||
response = client.post("/form-model-fields-set", data=data)
|
||||
assert response.json() == {"fields_set": []}
|
||||
|
||||
|
||||
def test_form_default_url_encoded(client: TestClient):
|
||||
response = client.post("/urlencoded", data={"age": ""})
|
||||
assert response.status_code == 200
|
||||
assert response.text == "null"
|
||||
|
||||
|
||||
def test_form_default_multi_part():
|
||||
def test_form_default_multi_part(client: TestClient):
|
||||
response = client.post("/multipart", data={"age": ""})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"file": None, "age": None}
|
||||
|
|
|
|||
|
|
@ -121,13 +121,13 @@ def test_no_data():
|
|||
"type": "missing",
|
||||
"loc": ["body", "username"],
|
||||
"msg": "Field required",
|
||||
"input": {"tags": ["foo", "bar"], "with": "nothing"},
|
||||
"input": {},
|
||||
},
|
||||
{
|
||||
"type": "missing",
|
||||
"loc": ["body", "lastname"],
|
||||
"msg": "Field required",
|
||||
"input": {"tags": ["foo", "bar"], "with": "nothing"},
|
||||
"input": {},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue