mirror of https://github.com/tiangolo/fastapi.git
Update tests
This commit is contained in:
parent
8033b02f64
commit
4c68df1804
|
|
@ -0,0 +1,193 @@
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
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(default_factory=list)],
|
||||||
|
):
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
# ====================================================================================
|
||||||
|
# Tests
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client():
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
@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": []}
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import FastAPI, Form
|
|
||||||
from fastapi._compat import PYDANTIC_V2
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from starlette.testclient import TestClient
|
|
||||||
from typing_extensions import Annotated
|
|
||||||
|
|
||||||
from .utils import needs_pydanticv2
|
|
||||||
|
|
||||||
if PYDANTIC_V2:
|
|
||||||
from pydantic import model_validator
|
|
||||||
else:
|
|
||||||
from pydantic import root_validator
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_input(value: dict) -> dict:
|
|
||||||
"""
|
|
||||||
model validators in before mode should receive values passed
|
|
||||||
to model instantiation before any further validation
|
|
||||||
"""
|
|
||||||
# we should not be double-instantiating the models
|
|
||||||
assert isinstance(value, dict)
|
|
||||||
value["init_input"] = value.copy()
|
|
||||||
|
|
||||||
# differentiate between explicit Nones and unpassed values
|
|
||||||
if "true_if_unset" not in value:
|
|
||||||
value["true_if_unset"] = True
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class Parent(BaseModel):
|
|
||||||
init_input: dict
|
|
||||||
# importantly, no default here
|
|
||||||
|
|
||||||
if PYDANTIC_V2:
|
|
||||||
|
|
||||||
@model_validator(mode="before")
|
|
||||||
def validate_inputs(cls, value: dict) -> dict:
|
|
||||||
return _validate_input(value)
|
|
||||||
else:
|
|
||||||
|
|
||||||
@root_validator(pre=True)
|
|
||||||
def validate_inputs(cls, value: dict) -> dict:
|
|
||||||
return _validate_input(value)
|
|
||||||
|
|
||||||
|
|
||||||
class StandardModel(Parent):
|
|
||||||
default_true: bool = True
|
|
||||||
default_false: bool = False
|
|
||||||
default_none: Optional[bool] = None
|
|
||||||
default_zero: int = 0
|
|
||||||
default_str: str = "foo"
|
|
||||||
true_if_unset: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
class FieldModel(Parent):
|
|
||||||
default_true: bool = Field(default=True)
|
|
||||||
default_false: bool = Field(default=False)
|
|
||||||
default_none: Optional[bool] = Field(default=None)
|
|
||||||
default_zero: int = Field(default=0)
|
|
||||||
default_str: str = Field(default="foo")
|
|
||||||
true_if_unset: Optional[bool] = Field(default=None)
|
|
||||||
|
|
||||||
|
|
||||||
if PYDANTIC_V2:
|
|
||||||
|
|
||||||
class AnnotatedFieldModel(Parent):
|
|
||||||
default_true: Annotated[bool, Field(default=True)]
|
|
||||||
default_false: Annotated[bool, Field(default=False)]
|
|
||||||
default_none: Annotated[Optional[bool], Field(default=None)]
|
|
||||||
default_zero: Annotated[int, Field(default=0)]
|
|
||||||
default_str: Annotated[str, Field(default="foo")]
|
|
||||||
true_if_unset: Annotated[Optional[bool], Field(default=None)]
|
|
||||||
|
|
||||||
class AnnotatedFormModel(Parent):
|
|
||||||
default_true: Annotated[bool, Form(default=True)]
|
|
||||||
default_false: Annotated[bool, Form(default=False)]
|
|
||||||
default_none: Annotated[Optional[bool], Form(default=None)]
|
|
||||||
default_zero: Annotated[int, Form(default=0)]
|
|
||||||
default_str: Annotated[str, Form(default="foo")]
|
|
||||||
true_if_unset: Annotated[Optional[bool], Form(default=None)]
|
|
||||||
|
|
||||||
class SimpleForm(BaseModel):
|
|
||||||
"""https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172"""
|
|
||||||
|
|
||||||
foo: Annotated[str, Form(default="bar")]
|
|
||||||
alias_with: Annotated[str, Form(alias="with", default="nothing")]
|
|
||||||
|
|
||||||
|
|
||||||
class ResponseModel(BaseModel):
|
|
||||||
fields_set: list = Field(default_factory=list)
|
|
||||||
dumped_fields_no_exclude: dict = Field(default_factory=dict)
|
|
||||||
dumped_fields_exclude_default: dict = Field(default_factory=dict)
|
|
||||||
dumped_fields_exclude_unset: dict = Field(default_factory=dict)
|
|
||||||
dumped_fields_no_meta: dict = Field(default_factory=dict)
|
|
||||||
init_input: dict
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_value(cls, value: Parent) -> "ResponseModel":
|
|
||||||
if PYDANTIC_V2:
|
|
||||||
return ResponseModel(
|
|
||||||
init_input=value.init_input,
|
|
||||||
fields_set=list(value.model_fields_set),
|
|
||||||
dumped_fields_no_exclude=value.model_dump(),
|
|
||||||
dumped_fields_exclude_default=value.model_dump(exclude_defaults=True),
|
|
||||||
dumped_fields_exclude_unset=value.model_dump(exclude_unset=True),
|
|
||||||
dumped_fields_no_meta=value.model_dump(
|
|
||||||
exclude={"init_input", "fields_set"}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return ResponseModel(
|
|
||||||
init_input=value.init_input,
|
|
||||||
fields_set=list(value.__fields_set__),
|
|
||||||
dumped_fields_no_exclude=value.dict(),
|
|
||||||
dumped_fields_exclude_default=value.dict(exclude_defaults=True),
|
|
||||||
dumped_fields_exclude_unset=value.dict(exclude_unset=True),
|
|
||||||
dumped_fields_no_meta=value.dict(exclude={"init_input", "fields_set"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/form/standard")
|
|
||||||
async def form_standard(value: Annotated[StandardModel, Form()]) -> ResponseModel:
|
|
||||||
return ResponseModel.from_value(value)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/form/field")
|
|
||||||
async def form_field(value: Annotated[FieldModel, Form()]) -> ResponseModel:
|
|
||||||
return ResponseModel.from_value(value)
|
|
||||||
|
|
||||||
|
|
||||||
if PYDANTIC_V2:
|
|
||||||
|
|
||||||
@app.post("/form/annotated-field")
|
|
||||||
async def form_annotated_field(
|
|
||||||
value: Annotated[AnnotatedFieldModel, Form()],
|
|
||||||
) -> ResponseModel:
|
|
||||||
return ResponseModel.from_value(value)
|
|
||||||
|
|
||||||
@app.post("/form/annotated-form")
|
|
||||||
async def form_annotated_form(
|
|
||||||
value: Annotated[AnnotatedFormModel, Form()],
|
|
||||||
) -> ResponseModel:
|
|
||||||
return ResponseModel.from_value(value)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/form/inlined")
|
|
||||||
async def form_inlined(
|
|
||||||
default_true: Annotated[bool, Form()] = True,
|
|
||||||
default_false: Annotated[bool, Form()] = False,
|
|
||||||
default_none: Annotated[Optional[bool], Form()] = None,
|
|
||||||
default_zero: Annotated[int, Form()] = 0,
|
|
||||||
default_str: Annotated[str, Form()] = "foo",
|
|
||||||
true_if_unset: Annotated[Optional[bool], Form()] = None,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Rather than using a model, inline the fields in the endpoint.
|
|
||||||
|
|
||||||
This doesn't use the `ResponseModel` pattern, since that is just to
|
|
||||||
test the instantiation behavior prior to the endpoint function.
|
|
||||||
Since we are receiving the values of the fields here (and thus,
|
|
||||||
having the defaults is correct behavior), we just return the values.
|
|
||||||
"""
|
|
||||||
if true_if_unset is None:
|
|
||||||
true_if_unset = True
|
|
||||||
|
|
||||||
return {
|
|
||||||
"default_true": default_true,
|
|
||||||
"default_false": default_false,
|
|
||||||
"default_none": default_none,
|
|
||||||
"default_zero": default_zero,
|
|
||||||
"default_str": default_str,
|
|
||||||
"true_if_unset": true_if_unset,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/json/standard")
|
|
||||||
async def json_standard(value: StandardModel) -> ResponseModel:
|
|
||||||
return ResponseModel.from_value(value)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/json/field")
|
|
||||||
async def json_field(value: FieldModel) -> ResponseModel:
|
|
||||||
return ResponseModel.from_value(value)
|
|
||||||
|
|
||||||
|
|
||||||
if PYDANTIC_V2:
|
|
||||||
|
|
||||||
@app.post("/json/annotated-field")
|
|
||||||
async def json_annotated_field(value: AnnotatedFieldModel) -> ResponseModel:
|
|
||||||
return ResponseModel.from_value(value)
|
|
||||||
|
|
||||||
@app.post("/json/annotated-form")
|
|
||||||
async def json_annotated_form(value: AnnotatedFormModel) -> ResponseModel:
|
|
||||||
return ResponseModel.from_value(value)
|
|
||||||
|
|
||||||
@app.post("/simple-form")
|
|
||||||
def form_endpoint(model: Annotated[SimpleForm, Form()]) -> dict:
|
|
||||||
"""https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172"""
|
|
||||||
return model.model_dump()
|
|
||||||
|
|
||||||
|
|
||||||
if PYDANTIC_V2:
|
|
||||||
MODEL_TYPES = {
|
|
||||||
"standard": StandardModel,
|
|
||||||
"field": FieldModel,
|
|
||||||
"annotated-field": AnnotatedFieldModel,
|
|
||||||
"annotated-form": AnnotatedFormModel,
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
MODEL_TYPES = {
|
|
||||||
"standard": StandardModel,
|
|
||||||
"field": FieldModel,
|
|
||||||
}
|
|
||||||
ENCODINGS = ("form", "json")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def client() -> TestClient:
|
|
||||||
with TestClient(app) as test_client:
|
|
||||||
yield test_client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("encoding", ENCODINGS)
|
|
||||||
@pytest.mark.parametrize("model_type", MODEL_TYPES.keys())
|
|
||||||
def test_no_prefill_defaults_all_unset(encoding, model_type, client):
|
|
||||||
"""
|
|
||||||
When the model is instantiated by the server, it should not have its defaults prefilled
|
|
||||||
"""
|
|
||||||
|
|
||||||
endpoint = f"/{encoding}/{model_type}"
|
|
||||||
if encoding == "form":
|
|
||||||
res = client.post(endpoint, data={})
|
|
||||||
else:
|
|
||||||
res = client.post(endpoint, json={})
|
|
||||||
|
|
||||||
assert res.status_code == 200
|
|
||||||
response_model = ResponseModel(**res.json())
|
|
||||||
assert response_model.init_input == {}
|
|
||||||
assert len(response_model.fields_set) == 2
|
|
||||||
assert response_model.dumped_fields_no_exclude["true_if_unset"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("encoding", ENCODINGS)
|
|
||||||
@pytest.mark.parametrize("model_type", MODEL_TYPES.keys())
|
|
||||||
def test_no_prefill_defaults_partially_set(encoding, model_type, client):
|
|
||||||
"""
|
|
||||||
When the model is instantiated by the server, it should not have its defaults prefilled,
|
|
||||||
and pydantic should be able to differentiate between unset and default values when some are passed
|
|
||||||
"""
|
|
||||||
endpoint = f"/{encoding}/{model_type}"
|
|
||||||
if encoding == "form":
|
|
||||||
data = {"true_if_unset": "False", "default_false": "True", "default_zero": "0"}
|
|
||||||
res = client.post(endpoint, data=data)
|
|
||||||
else:
|
|
||||||
data = {"true_if_unset": False, "default_false": True, "default_zero": 0}
|
|
||||||
res = client.post(endpoint, json=data)
|
|
||||||
|
|
||||||
if PYDANTIC_V2:
|
|
||||||
dumped_exclude_unset = MODEL_TYPES[model_type](**data).model_dump(
|
|
||||||
exclude_unset=True
|
|
||||||
)
|
|
||||||
dumped_exclude_default = MODEL_TYPES[model_type](**data).model_dump(
|
|
||||||
exclude_defaults=True
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
dumped_exclude_unset = MODEL_TYPES[model_type](**data).dict(exclude_unset=True)
|
|
||||||
dumped_exclude_default = MODEL_TYPES[model_type](**data).dict(
|
|
||||||
exclude_defaults=True
|
|
||||||
)
|
|
||||||
|
|
||||||
assert res.status_code == 200
|
|
||||||
response_model = ResponseModel(**res.json())
|
|
||||||
assert response_model.init_input == data
|
|
||||||
assert len(response_model.fields_set) == 4
|
|
||||||
assert response_model.dumped_fields_exclude_unset == dumped_exclude_unset
|
|
||||||
assert response_model.dumped_fields_no_exclude["true_if_unset"] is False
|
|
||||||
assert "default_zero" not in dumped_exclude_default
|
|
||||||
assert "default_zero" not in response_model.dumped_fields_exclude_default
|
|
||||||
|
|
||||||
|
|
||||||
@needs_pydanticv2
|
|
||||||
def test_casted_empty_defaults(client: TestClient):
|
|
||||||
"""https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172"""
|
|
||||||
form_content = {"foo": "", "with": ""}
|
|
||||||
response = client.post("/simple-form", data=form_content)
|
|
||||||
response_content = response.json()
|
|
||||||
assert response_content["foo"] == "bar" # Expected :'bar' -> Actual :''
|
|
||||||
assert response_content["alias_with"] == "nothing" # ok
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("model_type", list(MODEL_TYPES.keys()) + ["inlined"])
|
|
||||||
def test_empty_string_inputs(model_type, client):
|
|
||||||
"""
|
|
||||||
Form inputs with no input are empty strings,
|
|
||||||
these should be treated as being unset.
|
|
||||||
"""
|
|
||||||
data = {
|
|
||||||
"default_true": "",
|
|
||||||
"default_false": "",
|
|
||||||
"default_none": "",
|
|
||||||
"default_str": "",
|
|
||||||
"default_zero": "",
|
|
||||||
"true_if_unset": "",
|
|
||||||
}
|
|
||||||
response = client.post(f"/form/{model_type}", data=data)
|
|
||||||
assert response.status_code == 200
|
|
||||||
if model_type != "inlined":
|
|
||||||
response_model = ResponseModel(**response.json())
|
|
||||||
assert set(response_model.fields_set) == {"true_if_unset", "init_input"}
|
|
||||||
response_data = response_model.dumped_fields_no_meta
|
|
||||||
else:
|
|
||||||
response_data = response.json()
|
|
||||||
assert response_data == {
|
|
||||||
"default_true": True,
|
|
||||||
"default_false": False,
|
|
||||||
"default_none": None,
|
|
||||||
"default_zero": 0,
|
|
||||||
"default_str": "foo",
|
|
||||||
"true_if_unset": True,
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue