Files
fastapi/tests/test_forms_multiple_models.py

642 lines
22 KiB
Python

from typing import Annotated
from urllib.parse import urlencode
import pytest
from fastapi import APIRouter, Depends, FastAPI, File, Form, Query, UploadFile
from fastapi.testclient import TestClient
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
def form_schema(app, path="/", media_type="application/x-www-form-urlencoded"):
schema = app.openapi()
body = schema["paths"][path]["post"]["requestBody"]
ref = body["content"][media_type]["schema"]["$ref"]
return schema["components"]["schemas"][ref.rsplit("/", 1)[-1]]
def post_form(client, path, fields, multipart):
if multipart:
return client.post(path, files=[(key, (None, value)) for key, value in fields])
return client.post(
path,
content=urlencode(fields),
headers={"content-type": "application/x-www-form-urlencoded"},
)
@pytest.mark.parametrize("multipart", [False, True])
@pytest.mark.parametrize("path", ["/", "/dependency"])
def test_composed_form_models_ignore_extras(multipart, path):
app = FastAPI()
class Profile(BaseModel):
name: str
class Preferences(BaseModel):
limit: int = Field(10, ge=1, description="Result limit")
tags: list[str] = Field(default_factory=list)
@app.post("/")
def submit(
profile: Annotated[Profile, Form()],
preferences: Annotated[Preferences, Form()],
token: Annotated[str, Form()],
):
return {"profile": profile, "preferences": preferences, "token": token}
def profile_dependency(profile: Annotated[Profile, Form()]):
return profile
@app.post("/dependency")
def submit_dependency(
profile: Annotated[Profile, Depends(profile_dependency)],
preferences: Annotated[Preferences, Form()],
token: Annotated[str, Form()],
):
return {"profile": profile, "preferences": preferences, "token": token}
client = TestClient(app)
fields = [("name", "Rick"), ("token", "secret")]
response = post_form(
client, path, fields + [("limit", "2"), ("tags", "a"), ("tags", "b")], multipart
)
assert response.status_code == 200, response.text
assert response.json() == {
"profile": {"name": "Rick"},
"preferences": {"limit": 2, "tags": ["a", "b"]},
"token": "secret",
}
response = post_form(client, path, fields, multipart)
assert response.status_code == 200, response.text
assert response.json()["preferences"] == {"limit": 10, "tags": []}
response = post_form(client, path, fields + [("limit", "0")], multipart)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body", "limit"]
response = post_form(
client, path, fields + [("unknown", "a"), ("unknown", "b")], multipart
)
assert response.status_code == 200, response.text
for key in ("profile", "preferences"):
assert "unknown" not in response.json()[key]
response = post_form(client, path, [("token", "secret")], multipart)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body", "name"]
schema = form_schema(app, path)
assert set(schema["properties"]) == {"token"}
assert schema["required"] == ["token"]
assert {item["$ref"] for item in schema["allOf"]} == {
"#/components/schemas/Profile",
"#/components/schemas/Preferences",
}
models = app.openapi()["components"]["schemas"]
assert models["Preferences"]["properties"]["limit"]["minimum"] == 1
assert "additionalProperties" not in models["Profile"]
@pytest.mark.parametrize("multipart", [False, True])
@pytest.mark.parametrize("path", ["/", "/dependency"])
def test_composed_form_models_allow_extras(multipart, path):
app = FastAPI()
class Profile(BaseModel):
model_config = ConfigDict(extra="allow")
name: str
class Preferences(BaseModel):
model_config = ConfigDict(extra="allow")
limit: int = Field(10, ge=1)
tags: list[str] = Field(default_factory=list)
@app.post("/")
def submit(
profile: Annotated[Profile, Form()],
preferences: Annotated[Preferences, Form()],
token: Annotated[str, Form()],
):
return {"profile": profile, "preferences": preferences, "token": token}
def profile_dependency(profile: Annotated[Profile, Form()]):
return profile
@app.post("/dependency")
def submit_dependency(
profile: Annotated[Profile, Depends(profile_dependency)],
preferences: Annotated[Preferences, Form()],
token: Annotated[str, Form()],
):
return {"profile": profile, "preferences": preferences, "token": token}
client = TestClient(app)
fields = [("name", "Rick"), ("token", "secret")]
response = post_form(
client, path, fields + [("limit", "2"), ("tags", "a"), ("tags", "b")], multipart
)
assert response.status_code == 200, response.text
assert response.json() == {
"profile": {
"name": "Rick",
"limit": "2",
"tags": ["a", "b"],
"token": "secret",
},
"preferences": {
"limit": 2,
"tags": ["a", "b"],
"name": "Rick",
"token": "secret",
},
"token": "secret",
}
response = post_form(client, path, fields, multipart)
assert response.status_code == 200, response.text
assert response.json()["preferences"] == {
"limit": 10,
"tags": [],
"name": "Rick",
"token": "secret",
}
response = post_form(client, path, fields + [("limit", "0")], multipart)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body", "limit"]
response = post_form(
client, path, fields + [("unknown", "a"), ("unknown", "b")], multipart
)
assert response.status_code == 200, response.text
for key in ("profile", "preferences"):
assert response.json()[key]["unknown"] == ["a", "b"]
response = post_form(client, path, [("token", "secret")], multipart)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body", "name"]
models = app.openapi()["components"]["schemas"]
assert models["Profile"]["additionalProperties"] is True
assert models["Preferences"]["additionalProperties"] is True
@pytest.mark.parametrize("multipart", [False, True])
@pytest.mark.parametrize("path", ["/", "/dependency"])
def test_composed_form_models_forbid_extras(multipart, path):
app = FastAPI()
class Profile(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
class Preferences(BaseModel):
model_config = ConfigDict(extra="forbid")
limit: int = 10
tags: list[str] = Field(default_factory=list)
@app.post("/")
def submit(
profile: Annotated[Profile, Form()],
preferences: Annotated[Preferences, Form()],
token: Annotated[str, Form()],
):
pytest.fail("Invalid form data reached the endpoint") # pragma: no cover
def profile_dependency(profile: Annotated[Profile, Form()]):
pytest.fail("Invalid form data reached the dependency") # pragma: no cover
@app.post("/dependency")
def submit_dependency(
profile: Annotated[Profile, Depends(profile_dependency)],
preferences: Annotated[Preferences, Form()],
token: Annotated[str, Form()],
):
pytest.fail("Invalid form data reached the endpoint") # pragma: no cover
response = post_form(
TestClient(app),
path,
[
("name", "Rick"),
("token", "secret"),
("limit", "2"),
("tags", "a"),
("tags", "b"),
],
multipart,
)
assert response.status_code == 422
assert {error["loc"][-1] for error in response.json()["detail"]} == {
"limit",
"tags",
"token",
"name",
}
assert all(
error["type"] == "extra_forbidden" for error in response.json()["detail"]
)
models = app.openapi()["components"]["schemas"]
assert models["Profile"]["additionalProperties"] is False
assert models["Preferences"]["additionalProperties"] is False
def test_model_defaults_and_validators_are_independent():
app = FastAPI()
inputs = []
class Settings(BaseModel):
label: str = "default"
enabled: bool = True
tags: list[str] = Field(default_factory=list)
@model_validator(mode="before")
@classmethod
def check_input(cls, value):
inputs.append(value.copy())
return value
@model_validator(mode="after")
def check_label(self):
if self.label == "invalid":
raise ValueError("Invalid label")
return self
class OtherSettings(BaseModel):
label: str = "other"
enabled: str = "other"
@app.post("/")
def submit(
settings: Annotated[Settings, Form()],
other: Annotated[OtherSettings, Form()],
token: Annotated[str, Form()] = "secret",
):
return {
"settings": settings,
"other": other,
"fields_set": sorted(settings.model_fields_set),
"other_fields_set": sorted(other.model_fields_set),
}
client = TestClient(app)
response = client.post("/")
assert response.status_code == 200, response.text
assert response.json() == {
"settings": {"label": "default", "enabled": True, "tags": []},
"other": {"label": "other", "enabled": "other"},
"fields_set": [],
"other_fields_set": [],
}
response = client.post(
"/", data={"label": "", "enabled": "", "tags": ["", "a"], "token": "ok"}
)
assert response.status_code == 200, response.text
assert response.json() == {
"settings": {"label": "default", "enabled": True, "tags": ["", "a"]},
"other": {"label": "other", "enabled": "other"},
"fields_set": ["tags"],
"other_fields_set": [],
}
assert inputs[-1]["token"] == "ok"
response = client.post("/", data={"label": "invalid"})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body"]
@pytest.mark.parametrize("required_first", [False, True])
def test_aliases_shared_fields_and_cached_models(required_first):
app = FastAPI()
class First(BaseModel):
model_config = ConfigDict(populate_by_name=True)
value: int = Field(alias="first", description="First value")
shared: int = Field() if required_first else Field(10)
class Second(BaseModel):
value: int = Field(alias="second")
shared: int = Field(10) if required_first else Field()
@app.post("/")
def submit(first: Annotated[First, Form()], second: Annotated[Second, Form()]):
return [first, second]
@app.post("/single")
def single(data: Annotated[First, Form()]):
return data
@app.post("/json")
def json_body(first: First, second: Second):
return [first, second]
client = TestClient(app)
response = client.post("/", data={"value": "1", "second": "2", "shared": "3"})
assert response.status_code == 200, response.text
expected = [{"first": 1, "shared": 3}, {"second": 2, "shared": 3}]
assert response.json() == expected
assert client.post("/", data={"first": "1", "second": "2"}).status_code == 422
schema = form_schema(app)
original_schema = app.openapi()["components"]["schemas"]["First"]
assert schema["allOf"] == [
{
**original_schema,
"required": ["shared"] if required_first else [],
"allOf": [{"anyOf": [{"required": ["first"]}, {"required": ["value"]}]}],
},
{"$ref": "#/components/schemas/Second"},
]
assert set(form_schema(app, "/single")["properties"]) == {"first", "shared"}
assert set(form_schema(app, "/json", "application/json")["properties"]) == {
"first",
"second",
}
assert First.model_fields["value"].alias == "first"
assert First.model_fields["shared"].alias is None
assert First.model_fields["shared"].is_required() is required_first
response = client.post("/single", data={"first": "4", "shared": "5"})
assert response.status_code == 200, response.text
assert response.json() == {"first": 4, "shared": 5}
response = client.post(
"/json",
json={"first": {"first": 1, "shared": 3}, "second": {"second": 2, "shared": 3}},
)
assert response.status_code == 200, response.text
def test_shared_alias_choices_keep_shapes_and_unused_extras():
app = FastAPI()
class Many(BaseModel):
model_config = ConfigDict(extra="forbid")
names: list[str] = Field(validation_alias=AliasChoices("names", "legacy"))
class One(BaseModel):
text: str = Field(alias="legacy")
@app.post("/")
def submit(
many: Annotated[Many, Form()],
one: Annotated[One, Form()],
legacy: Annotated[str, Form()],
):
return {"many": many, "one": one, "last": legacy}
client = TestClient(app)
response = client.post("/", data={"legacy": ["first", "last"]})
assert response.status_code == 200, response.text
assert response.json() == {
"many": {"names": ["first", "last"]},
"one": {"legacy": "last"},
"last": "last",
}
response = client.post("/", data={"names": "first", "legacy": "last"})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body", "legacy"]
def test_nested_dependencies_overrides_and_request_locations():
app = FastAPI()
class Profile(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
class Preferences(BaseModel):
limit: int = 10
def preferences(data: Annotated[Preferences, Form()]):
return data.limit
def replacement(page: Annotated[int, Form()] = 1):
return page
def nested(value: Annotated[int, Depends(preferences)]):
return value
def token(token: Annotated[str, Form()] = "secret"):
return token
router = APIRouter(dependencies=[Depends(token)])
@router.post("/")
def submit(
data: Annotated[Profile, Form()],
value: Annotated[int, Depends(nested)],
query: Annotated[str, Query()] = "default",
):
return {"data": data, "value": value}
app.include_router(router)
client = TestClient(app)
response = client.post("/?query=ok", data={"name": "Rick"})
assert response.status_code == 200, response.text
assert response.json() == {"data": {"name": "Rick"}, "value": 10}
fields = {"name": "Rick", "token": "secret"}
response = client.post("/?query=ok", data={**fields, "limit": "2"})
assert response.status_code == 422
assert all(
error["type"] == "extra_forbidden" for error in response.json()["detail"]
)
assert {error["loc"][-1] for error in response.json()["detail"]} == {
"token",
"limit",
}
app.dependency_overrides[preferences] = replacement
response = client.post("/?query=ok", data={**fields, "page": "3"})
assert response.status_code == 422
assert [error["loc"] for error in response.json()["detail"]] == [
["body", "token"],
["body", "page"],
]
assert all(
error["type"] == "extra_forbidden" for error in response.json()["detail"]
)
@pytest.mark.parametrize("content", [b"", b"hello"])
def test_form_model_and_files_shared_with_dependencies(content):
app = FastAPI()
class Profile(BaseModel):
name: str
def inspect_files(
profile: Annotated[Profile, Form()], files: Annotated[list[bytes], File()]
):
return {"profile": profile, "files": [file.decode() for file in files]}
@app.post("/")
async def submit(
inspected: Annotated[dict, Depends(inspect_files)],
profile: Annotated[Profile, Form()],
files: Annotated[list[UploadFile], File()],
last: Annotated[bytes, File(alias="files")],
):
return {
"inspected": inspected,
"profile": profile,
"positions": [file.file.tell() for file in files],
"files": [(await file.read()).decode() for file in files],
"last": last.decode(),
}
client = TestClient(app)
response = client.post(
"/",
data={"name": "Rick"},
files=[("files", ("one.txt", b"one")), ("files", ("two.txt", content))],
)
assert response.status_code == 200, response.text
expected_files = ["one", content.decode()]
assert response.json() == {
"inspected": {"profile": {"name": "Rick"}, "files": expected_files},
"profile": {"name": "Rick"},
"positions": [0, 0],
"files": expected_files,
"last": content.decode(),
}
response = client.post("/", data={"name": "Rick"})
assert response.status_code == 422
assert all(error["loc"] == ["body", "files"] for error in response.json()["detail"])
schema = form_schema(app, media_type="multipart/form-data")
assert set(schema["properties"]) == {"files"}
assert schema["required"] == ["files"]
assert schema["properties"]["files"]["type"] == "array"
assert any(
item.get("properties", {}).get("files", {}).get("type") == "string"
for item in schema["allOf"]
)
def test_models_with_the_same_parameter_name_in_dependencies():
app = FastAPI()
class First(BaseModel):
first: int
class Second(BaseModel):
second: int
def dependency(data: Annotated[First, Form()]):
return data
@app.post("/")
def submit(
data: Annotated[Second, Form()], first: Annotated[First, Depends(dependency)]
):
return [first, data]
response = TestClient(app).post("/", data={"first": "1", "second": "2"})
assert response.status_code == 200, response.text
assert response.json() == [{"first": 1}, {"second": 2}]
assert {item["$ref"] for item in form_schema(app)["allOf"]} == {
"#/components/schemas/First",
"#/components/schemas/Second",
}
def test_optional_file_with_a_form_model():
app = FastAPI()
class Profile(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
@app.post("/")
def submit(
profile: Annotated[Profile, Form()],
attachment: Annotated[bytes | None, File()] = None,
):
return {"profile": profile, "attachment": attachment}
client = TestClient(app)
response = client.post("/", data={"name": "Rick"})
assert response.status_code == 200, response.text
assert response.json() == {"profile": {"name": "Rick"}, "attachment": None}
response = client.post(
"/", data={"name": "Rick"}, files={"attachment": ("empty.txt", b"")}
)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body", "attachment"]
assert response.json()["detail"][0]["type"] == "extra_forbidden"
schema = form_schema(app, media_type="multipart/form-data")
assert "required" not in schema
assert schema["allOf"][0]["$ref"] == "#/components/schemas/Profile"
assert (
app.openapi()["components"]["schemas"]["Profile"]["additionalProperties"]
is False
)
def test_dependency_override_adds_a_form_parameter():
app = FastAPI()
class Profile(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
def token():
return "default"
def replacement(token: Annotated[str, Form()]):
return token
@app.post("/")
def submit(
profile: Annotated[Profile, Form()], value: Annotated[str, Depends(token)]
):
return {"profile": profile, "token": value}
client = TestClient(app)
app.dependency_overrides[token] = replacement
response = client.post("/", data={"name": "Rick", "token": "secret"})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body", "token"]
assert response.json()["detail"][0]["type"] == "extra_forbidden"
response = client.post("/", data={"name": "Rick"})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body", "token"]
app.dependency_overrides.clear()
response = client.post("/", data={"name": "Rick", "token": "secret"})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["body", "token"]
response = client.post("/", data={"name": "Rick"})
assert response.status_code == 200, response.text
assert response.json() == {"profile": {"name": "Rick"}, "token": "default"}
def test_forbid_models_can_validate_the_same_fields():
app = FastAPI()
class Profile(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
age: int
class Adult(Profile):
age: int = Field(ge=18)
@app.post("/")
def submit(
profile: Annotated[Profile, Form()],
adult: Annotated[Adult, Form()],
name: Annotated[str, Form()],
):
return [profile, adult, name]
client = TestClient(app)
response = client.post("/", data={"name": "Rick", "age": "70"})
assert response.status_code == 200, response.text
assert response.json() == [
{"name": "Rick", "age": 70},
{"name": "Rick", "age": 70},
"Rick",
]
response = client.post("/", data={"name": "Rick", "age": "17"})
assert response.status_code == 422
assert response.json()["detail"][0]["type"] == "greater_than_equal"
response = client.post("/", data={"name": "Rick", "age": "70", "token": "secret"})
assert response.status_code == 422
assert [error["loc"] for error in response.json()["detail"]] == [
["body", "token"],
["body", "token"],
]
schemas = app.openapi()["components"]["schemas"]
assert {item["$ref"] for item in schemas["Body_submit__post"]["allOf"]} == {
"#/components/schemas/Profile",
"#/components/schemas/Adult",
}
assert schemas["Profile"]["additionalProperties"] is False
assert schemas["Adult"]["additionalProperties"] is False