mirror of https://github.com/tiangolo/fastapi.git
Merge 292c1fdeca into 272204c0c7
This commit is contained in:
commit
9a2eab07a0
|
|
@ -750,26 +750,33 @@ def _validate_value_with_model_field(
|
||||||
|
|
||||||
|
|
||||||
def _get_multidict_value(
|
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:
|
) -> Any:
|
||||||
alias = alias or get_validation_alias(field)
|
alias = alias or get_validation_alias(field)
|
||||||
if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)):
|
if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)):
|
||||||
value = values.getlist(alias)
|
value = values.getlist(alias)
|
||||||
else:
|
else:
|
||||||
value = values.get(alias, None)
|
value = values.get(alias, None)
|
||||||
if (
|
|
||||||
value is None
|
if form_input:
|
||||||
or (
|
# Special handling for form inputs:
|
||||||
isinstance(field.field_info, (params.Form, temp_pydantic_v1_params.Form))
|
# Treat empty strings or empty lists as missing values
|
||||||
and isinstance(value, str) # For type checks
|
if (isinstance(value, str) and value == "") or (
|
||||||
and value == ""
|
is_sequence_field(field) and len(value) == 0
|
||||||
)
|
):
|
||||||
or (is_sequence_field(field) and len(value) == 0)
|
return None
|
||||||
):
|
else:
|
||||||
if field.required:
|
# For non-form inputs:
|
||||||
return
|
# If value is None or an empty sequence, use the default (if not required) or
|
||||||
else:
|
# treat as missing (if required)
|
||||||
return deepcopy(field.default)
|
if value is None or (is_sequence_field(field) and len(value) == 0):
|
||||||
|
if field.required:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return deepcopy(field.default)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -906,7 +913,7 @@ async def _extract_form_body(
|
||||||
values = {}
|
values = {}
|
||||||
|
|
||||||
for field in body_fields:
|
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
|
field_info = field.field_info
|
||||||
if (
|
if (
|
||||||
isinstance(field_info, (params.File, temp_pydantic_v1_params.File))
|
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
|
import pytest
|
||||||
from starlette.testclient import TestClient
|
from fastapi import FastAPI, File, Form, UploadFile
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from typing_extensions import Annotated
|
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 = 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")
|
@app.post("/urlencoded")
|
||||||
async def post_url_encoded(age: Annotated[Optional[int], Form()] = None):
|
async def post_url_encoded(age: Annotated[Optional[int], Form()] = None):
|
||||||
return age
|
return age
|
||||||
|
|
@ -20,16 +81,137 @@ async def post_multi_part(
|
||||||
return {"file": file, "age": age}
|
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": ""})
|
response = client.post("/urlencoded", data={"age": ""})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.text == "null"
|
assert response.text == "null"
|
||||||
|
|
||||||
|
|
||||||
def test_form_default_multi_part():
|
def test_form_default_multi_part(client: TestClient):
|
||||||
response = client.post("/multipart", data={"age": ""})
|
response = client.post("/multipart", data={"age": ""})
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {"file": None, "age": None}
|
assert response.json() == {"file": None, "age": None}
|
||||||
|
|
|
||||||
|
|
@ -121,13 +121,13 @@ def test_no_data():
|
||||||
"type": "missing",
|
"type": "missing",
|
||||||
"loc": ["body", "username"],
|
"loc": ["body", "username"],
|
||||||
"msg": "Field required",
|
"msg": "Field required",
|
||||||
"input": {"tags": ["foo", "bar"], "with": "nothing"},
|
"input": {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "missing",
|
"type": "missing",
|
||||||
"loc": ["body", "lastname"],
|
"loc": ["body", "lastname"],
|
||||||
"msg": "Field required",
|
"msg": "Field required",
|
||||||
"input": {"tags": ["foo", "bar"], "with": "nothing"},
|
"input": {},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue