mirror of
https://github.com/fastapi/fastapi.git
synced 2026-10-29 21:30:48 -04:00
765 lines
24 KiB
Python
765 lines
24 KiB
Python
from typing import Annotated, Literal
|
|
|
|
import pytest
|
|
from fastapi import Body, Depends, FastAPI, File, Form, UploadFile
|
|
from fastapi._compat import PYDANTIC_VERSION_MINOR_TUPLE
|
|
from fastapi.testclient import TestClient
|
|
from pydantic import (
|
|
AliasChoices,
|
|
BaseModel,
|
|
BeforeValidator,
|
|
ConfigDict,
|
|
Field,
|
|
computed_field,
|
|
)
|
|
|
|
|
|
class Person(BaseModel):
|
|
kind: Literal["person"] = Field(alias="contact_type")
|
|
name: str
|
|
tags: list[str] = Field(default_factory=list)
|
|
enabled: bool = False
|
|
|
|
|
|
class Company(BaseModel):
|
|
kind: Literal["company"] = Field(alias="contact_type")
|
|
company_name: str
|
|
|
|
|
|
class Email(BaseModel):
|
|
delivery: Literal["email"]
|
|
address: str
|
|
|
|
|
|
class Pickup(BaseModel):
|
|
delivery: Literal["pickup"]
|
|
location: str
|
|
|
|
|
|
class Settings(BaseModel):
|
|
priority: int = Field(default=1, ge=1)
|
|
|
|
|
|
app = FastAPI()
|
|
|
|
|
|
@app.post("/combined")
|
|
def combined(
|
|
contact: Annotated[Person | Company, Form(discriminator="kind")],
|
|
delivery: Annotated[Email | Pickup, Form(discriminator="delivery")],
|
|
settings: Annotated[Settings, Form()],
|
|
token: Annotated[str, Form()],
|
|
):
|
|
return {
|
|
"contact": contact,
|
|
"delivery": delivery,
|
|
"settings": settings,
|
|
"token": token,
|
|
}
|
|
|
|
|
|
@app.post("/optional")
|
|
def optional(
|
|
token: Annotated[str, Form()],
|
|
contact: Annotated[Person | Company | None, Form(discriminator="kind")] = None,
|
|
delivery: Annotated[Email | Pickup | None, Form(discriminator="delivery")] = None,
|
|
):
|
|
return {"contact": contact, "delivery": delivery}
|
|
|
|
|
|
@app.post("/ordinary")
|
|
def ordinary(
|
|
contact: Annotated[Person | Company, Form()], token: Annotated[str, Form()]
|
|
):
|
|
return contact
|
|
|
|
|
|
@app.post("/files")
|
|
async def files(
|
|
contact: Annotated[Person | Company, Form(discriminator="kind")],
|
|
avatar: Annotated[UploadFile, File()],
|
|
content: Annotated[bytes, File(alias="avatar")],
|
|
attachments: Annotated[list[bytes] | None, File()] = None,
|
|
):
|
|
return {
|
|
"contact": contact,
|
|
"position": avatar.file.tell(),
|
|
"avatar": await avatar.read(),
|
|
"content": content,
|
|
"attachments": attachments,
|
|
}
|
|
|
|
|
|
@app.post("/json")
|
|
def json_body(contact: Person | Company, token: Annotated[str, Body()]):
|
|
return contact
|
|
|
|
|
|
client = TestClient(app)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"contact,expected_contact",
|
|
[
|
|
(
|
|
{"contact_type": "person", "name": "Rick", "tags": ["", "blue"]},
|
|
{
|
|
"contact_type": "person",
|
|
"name": "Rick",
|
|
"tags": ["", "blue"],
|
|
"enabled": False,
|
|
},
|
|
),
|
|
(
|
|
{"contact_type": "company", "company_name": "Acme"},
|
|
{"contact_type": "company", "company_name": "Acme"},
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"delivery",
|
|
[
|
|
{"delivery": "email", "address": "rick@example.com"},
|
|
{"delivery": "pickup", "location": "Office"},
|
|
],
|
|
)
|
|
def test_multiple_unions_with_model_and_scalar(contact, expected_contact, delivery):
|
|
response = client.post(
|
|
"/combined", data={**contact, **delivery, "token": "secret", "priority": "2"}
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {
|
|
"contact": expected_contact,
|
|
"delivery": delivery,
|
|
"settings": {"priority": 2},
|
|
"token": "secret",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fields,expected",
|
|
[
|
|
({"token": "secret"}, {"contact": None, "delivery": None}),
|
|
(
|
|
{"token": "secret", "contact_type": "company", "company_name": "Acme"},
|
|
{
|
|
"contact": {"contact_type": "company", "company_name": "Acme"},
|
|
"delivery": None,
|
|
},
|
|
),
|
|
(
|
|
{"token": "secret", "delivery": "pickup", "location": "Office"},
|
|
{"contact": None, "delivery": {"delivery": "pickup", "location": "Office"}},
|
|
),
|
|
],
|
|
)
|
|
def test_optional_unions_are_independent(fields, expected):
|
|
response = client.post("/optional", data=fields)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == expected
|
|
|
|
|
|
def test_partial_and_empty_optional_union():
|
|
response = client.post("/optional", data={"token": "secret", "name": ""})
|
|
assert response.status_code == 422
|
|
assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [
|
|
("union_tag_not_found", ["body"])
|
|
]
|
|
response = client.post(
|
|
"/optional", data={"token": "secret", "contact_type": "person"}
|
|
)
|
|
assert response.status_code == 422
|
|
assert response.json()["detail"][0]["loc"] == ["body", "person", "name"]
|
|
|
|
|
|
def test_forbid_union_branch_rejects_siblings_and_unselected_fields():
|
|
app = FastAPI()
|
|
|
|
class ClosedPerson(Person):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
@app.post("/")
|
|
def submit(
|
|
contact: Annotated[ClosedPerson | Company, Form(discriminator="kind")],
|
|
token: Annotated[str | None, Form()] = None,
|
|
):
|
|
return contact
|
|
|
|
client = TestClient(app)
|
|
fields = {"contact_type": "person", "name": "Rick"}
|
|
assert client.post("/", data=fields).status_code == 200
|
|
for extra in ({"token": "secret"}, {"company_name": "Acme"}):
|
|
response = client.post("/", data={**fields, **extra})
|
|
assert response.status_code == 422
|
|
assert response.json()["detail"][0]["type"] == "extra_forbidden"
|
|
assert response.json()["detail"][0]["loc"] == [
|
|
"body",
|
|
"person",
|
|
next(iter(extra)),
|
|
]
|
|
response = client.post(
|
|
"/", data={"contact_type": "company", "company_name": "Acme", "token": "secret"}
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
schema = app.openapi()["components"]["schemas"]
|
|
union = schema["Body_submit__post"]["allOf"][0]
|
|
assert (
|
|
union["discriminator"]["mapping"]["person"]
|
|
== "#/components/schemas/ClosedPerson"
|
|
)
|
|
assert schema["ClosedPerson"]["additionalProperties"] is False
|
|
assert "token" not in schema["ClosedPerson"]["properties"]
|
|
|
|
|
|
def test_optional_forbid_union_is_activated_by_sibling_fields():
|
|
app = FastAPI()
|
|
|
|
class ClosedPerson(Person):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
@app.post("/")
|
|
def submit(
|
|
token: Annotated[str | None, Form()] = None,
|
|
contact: Annotated[
|
|
ClosedPerson | Company | None, Form(discriminator="kind")
|
|
] = None,
|
|
):
|
|
return contact
|
|
|
|
client = TestClient(app)
|
|
assert client.post("/").json() is None
|
|
response = client.post("/", data={"token": "secret"})
|
|
assert response.status_code == 422
|
|
assert response.json()["detail"][0]["type"] == "union_tag_not_found"
|
|
schema = app.openapi()["components"]["schemas"]["Body_submit__post"]
|
|
assert schema["allOf"][0]["anyOf"][0] == {"maxProperties": 0}
|
|
|
|
|
|
def test_ordinary_union():
|
|
response = client.post(
|
|
"/ordinary",
|
|
data={"token": "secret", "contact_type": "company", "company_name": "Acme"},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"contact_type": "company", "company_name": "Acme"}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"mode,expected", [("smart", "Detailed"), ("left_to_right", "Simple")]
|
|
)
|
|
def test_pydantic_selects_branch_with_sibling_fields(mode, expected):
|
|
app = FastAPI()
|
|
|
|
class Simple(BaseModel):
|
|
name: str
|
|
|
|
class Detailed(Simple):
|
|
count: int
|
|
|
|
@app.post("/")
|
|
def submit(
|
|
data: Annotated[Simple | Detailed, Form(union_mode=mode)],
|
|
count: Annotated[int, Form()],
|
|
):
|
|
return {"model": type(data).__name__, "count": count}
|
|
|
|
response = TestClient(app).post("/", data={"name": "Rick", "count": "2"})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"model": expected, "count": 2}
|
|
|
|
|
|
def test_repeated_values_are_extracted_independently():
|
|
app = FastAPI()
|
|
|
|
class Text(BaseModel):
|
|
value: str
|
|
|
|
class Items(BaseModel):
|
|
value: list[str]
|
|
|
|
@app.post("/")
|
|
def submit(
|
|
text: Annotated[Text | Company, Form()],
|
|
items: Annotated[Items | Company, Form()],
|
|
value: Annotated[str, Form()],
|
|
):
|
|
return {"text": text, "items": items, "value": value}
|
|
|
|
response = TestClient(app).post("/", data={"value": ["", "blue"]})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {
|
|
"text": {"value": "blue"},
|
|
"items": {"value": ["", "blue"]},
|
|
"value": "blue",
|
|
}
|
|
|
|
|
|
def test_union_with_files():
|
|
response = client.post(
|
|
"/files",
|
|
data={"contact_type": "person", "name": "Rick", "tags": ["", "blue"]},
|
|
files=[
|
|
("avatar", ("avatar.txt", b"hello")),
|
|
("attachments", ("empty.txt", b"")),
|
|
("attachments", ("other.txt", b"other")),
|
|
],
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {
|
|
"contact": {
|
|
"contact_type": "person",
|
|
"name": "Rick",
|
|
"tags": ["", "blue"],
|
|
"enabled": False,
|
|
},
|
|
"position": 0,
|
|
"avatar": "hello",
|
|
"content": "hello",
|
|
"attachments": ["", "other"],
|
|
}
|
|
response = client.post(
|
|
"/files",
|
|
data={"contact_type": "company", "company_name": "Acme"},
|
|
files={"avatar": ("empty.txt", b"")},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["attachments"] is None
|
|
|
|
|
|
def test_union_upload_is_shared_with_file_parameter():
|
|
app = FastAPI()
|
|
|
|
class Uploaded(BaseModel):
|
|
upload: UploadFile
|
|
|
|
@app.post("/")
|
|
async def submit(
|
|
data: Annotated[Uploaded | Company, Form()],
|
|
content: Annotated[bytes, File(alias="upload")],
|
|
):
|
|
assert isinstance(data, Uploaded)
|
|
return {
|
|
"position": data.upload.file.tell(),
|
|
"upload": await data.upload.read(),
|
|
"content": content,
|
|
}
|
|
|
|
response = TestClient(app).post("/", files={"upload": ("hello.txt", b"hello")})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"position": 0, "upload": "hello", "content": "hello"}
|
|
|
|
|
|
def test_optional_union_with_file_only():
|
|
app = FastAPI()
|
|
|
|
@app.post("/")
|
|
def submit(
|
|
attachment: Annotated[bytes, File()],
|
|
contact: Annotated[Person | Company | None, Form(discriminator="kind")] = None,
|
|
):
|
|
return contact
|
|
|
|
response = TestClient(app).post("/", files={"attachment": ("file.txt", b"hello")})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() is None
|
|
|
|
|
|
def test_multiple_unions_multipart():
|
|
response = client.post(
|
|
"/optional",
|
|
files={
|
|
"token": (None, "secret"),
|
|
"contact_type": (None, "company"),
|
|
"company_name": (None, "Acme"),
|
|
"delivery": (None, "pickup"),
|
|
"location": (None, "Office"),
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {
|
|
"contact": {"contact_type": "company", "company_name": "Acme"},
|
|
"delivery": {"delivery": "pickup", "location": "Office"},
|
|
}
|
|
|
|
|
|
def test_union_with_optional_model_and_defaults():
|
|
app = FastAPI()
|
|
default = Company(contact_type="company", company_name="Default")
|
|
|
|
@app.post("/")
|
|
def submit(
|
|
token: Annotated[str, Form()],
|
|
contact: Annotated[
|
|
Person | Company | None, Form(discriminator="kind")
|
|
] = default,
|
|
settings: Annotated[Settings | None, Form()] = None,
|
|
):
|
|
return {"contact": contact, "settings": settings}
|
|
|
|
client = TestClient(app)
|
|
response = client.post("/", data={"token": "secret", "priority": "2"})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {
|
|
"contact": {"contact_type": "company", "company_name": "Default"},
|
|
"settings": {"priority": 2},
|
|
}
|
|
response = client.post(
|
|
"/", data={"token": "secret", "contact_type": "company", "company_name": "Acme"}
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["settings"] is None
|
|
schema = app.openapi()["components"]["schemas"]["Body_submit__post"]
|
|
assert len(schema["allOf"]) == 2
|
|
assert schema["required"] == ["token"]
|
|
|
|
|
|
def test_required_nullable_union_with_defaulted_fields():
|
|
app = FastAPI()
|
|
|
|
class Count(BaseModel):
|
|
count: int = 1
|
|
|
|
class Label(BaseModel):
|
|
label: str = "default"
|
|
|
|
@app.post("/")
|
|
def submit(
|
|
data: Annotated[Count | Label | None, Form()], token: Annotated[str, Form()]
|
|
):
|
|
return data
|
|
|
|
client = TestClient(app)
|
|
response = client.post("/", data={"token": "secret"})
|
|
assert response.status_code == 422
|
|
assert response.json()["detail"] == [
|
|
{"type": "missing", "loc": ["body"], "msg": "Field required", "input": None}
|
|
]
|
|
response = client.post("/", data={"token": "secret", "count": "2"})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"count": 2}
|
|
schema = app.openapi()["components"]["schemas"]["Body_submit__post"]
|
|
assert schema["allOf"][0] == {
|
|
"not": {"not": {"anyOf": [{"required": ["count"]}, {"required": ["label"]}]}}
|
|
}
|
|
|
|
|
|
def test_dependencies_with_same_parameter_name_and_overrides():
|
|
app = FastAPI()
|
|
|
|
def get_delivery(data: Annotated[Email | Pickup, Form(discriminator="delivery")]):
|
|
return data
|
|
|
|
def nested(delivery: Annotated[Email | Pickup, Depends(get_delivery)]):
|
|
return delivery
|
|
|
|
def replacement(page: Annotated[int, Form()]):
|
|
return page
|
|
|
|
@app.post("/")
|
|
def submit(
|
|
data: Annotated[Person | Company, Form(discriminator="kind")],
|
|
delivery: Annotated[Email | Pickup, Depends(nested)],
|
|
):
|
|
return {"contact": data, "delivery": delivery}
|
|
|
|
client = TestClient(app)
|
|
contact = {"contact_type": "company", "company_name": "Acme"}
|
|
response = client.post(
|
|
"/", data={**contact, "delivery": "pickup", "location": "Office"}
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {
|
|
"contact": contact,
|
|
"delivery": {"delivery": "pickup", "location": "Office"},
|
|
}
|
|
app.dependency_overrides[get_delivery] = replacement
|
|
response = client.post("/", data={**contact, "page": "2"})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"contact": contact, "delivery": 2}
|
|
response = client.post("/", data={**contact, "page": "2", "location": "Office"})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"contact": contact, "delivery": 2}
|
|
schema = app.openapi()
|
|
body = schema["components"]["schemas"]["Body_submit__post"]
|
|
assert len(body["allOf"]) == 2
|
|
|
|
|
|
@pytest.mark.parametrize("alias", ["full_name", "nickname"])
|
|
def test_alias_choices_activate_only_their_union(alias):
|
|
app = FastAPI()
|
|
|
|
class Named(BaseModel):
|
|
name: str = Field(validation_alias=AliasChoices("full_name", "nickname"))
|
|
|
|
@app.post("/")
|
|
def submit(
|
|
token: Annotated[str, Form()],
|
|
contact: Annotated[Named | Company | None, Form()] = None,
|
|
):
|
|
return contact
|
|
|
|
response = TestClient(app).post("/", data={"token": "secret", alias: "Rick"})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"name": "Rick"}
|
|
schema = app.openapi()
|
|
branch = schema["components"]["schemas"]["Named"]
|
|
assert branch["required"] == ["full_name"]
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
PYDANTIC_VERSION_MINOR_TUPLE < (2, 11),
|
|
reason="validate_by_alias requires Pydantic 2.11",
|
|
)
|
|
def test_aliases_disabled_in_model_schema():
|
|
app = FastAPI()
|
|
|
|
class Named(BaseModel):
|
|
model_config = ConfigDict(validate_by_alias=False)
|
|
name: str = Field(validation_alias=AliasChoices("full_name", "nickname"))
|
|
|
|
@app.post("/")
|
|
def submit(
|
|
contact: Annotated[Named | Company, Form()], token: Annotated[str, Form()]
|
|
):
|
|
return contact
|
|
|
|
response = TestClient(app).post("/", data={"token": "secret", "name": "Rick"})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"name": "Rick"}
|
|
schemas = app.openapi()["components"]["schemas"]
|
|
assert schemas["Named"] == Named.model_json_schema()
|
|
|
|
|
|
@pytest.mark.parametrize("default", [..., "default"])
|
|
def test_unused_aliases_are_forbidden(default):
|
|
app = FastAPI()
|
|
|
|
class Named(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
token: str
|
|
name: str = Field(
|
|
default=default, validation_alias=AliasChoices("name", "nickname")
|
|
)
|
|
|
|
@app.post("/")
|
|
def submit(
|
|
contact: Annotated[Named | Company, Form()], token: Annotated[str, Form()]
|
|
):
|
|
return contact
|
|
|
|
client = TestClient(app)
|
|
response = client.post("/", data={"token": "secret", "nickname": "Rick"})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"name": "Rick", "token": "secret"}
|
|
response = client.post(
|
|
"/", data={"token": "secret", "name": "Rick", "nickname": "Morty"}
|
|
)
|
|
assert response.status_code == 422
|
|
error = response.json()["detail"][0]
|
|
assert error["loc"] == ["body", "Named", "nickname"]
|
|
assert error["type"] == "extra_forbidden"
|
|
schema = app.openapi()["components"]["schemas"]["Named"]
|
|
assert schema["additionalProperties"] is False
|
|
|
|
|
|
def test_distinct_input_and_output_schemas():
|
|
app = FastAPI()
|
|
|
|
class Named(BaseModel):
|
|
name: str
|
|
|
|
@computed_field
|
|
@property
|
|
def uppercase(self) -> str:
|
|
return self.name.upper()
|
|
|
|
@app.post("/", response_model=Named | Company)
|
|
def submit(
|
|
contact: Annotated[Named | Company, Form()], token: Annotated[str, Form()]
|
|
):
|
|
return contact
|
|
|
|
response = TestClient(app).post("/", data={"token": "secret", "name": "Rick"})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"name": "Rick", "uppercase": "RICK"}
|
|
document = app.openapi()
|
|
schemas = document["components"]["schemas"]
|
|
body = schemas["Body_submit__post"]
|
|
branch_ref = body["allOf"][0]["anyOf"][0]["$ref"]
|
|
assert set(schemas[branch_ref.rsplit("/", 1)[1]]["properties"]) == {"name"}
|
|
output = document["paths"]["/"]["post"]["responses"]["200"]["content"][
|
|
"application/json"
|
|
]["schema"]
|
|
output_ref = output["anyOf"][0]["$ref"]
|
|
assert set(schemas[output_ref.rsplit("/", 1)[1]]["properties"]) == {
|
|
"name",
|
|
"uppercase",
|
|
}
|
|
|
|
|
|
def test_shared_fields_and_union_before_validator():
|
|
app = FastAPI()
|
|
calls = []
|
|
|
|
class Lower(BaseModel):
|
|
left: Literal["lower"]
|
|
count: int = Field(ge=1)
|
|
|
|
class Upper(BaseModel):
|
|
right: Literal["upper"]
|
|
count: int = Field(le=10)
|
|
|
|
def copy_input(data):
|
|
calls.append(data.copy())
|
|
return data.copy()
|
|
|
|
@app.post("/")
|
|
def submit(
|
|
left: Annotated[Lower | Company, BeforeValidator(copy_input), Form()],
|
|
right: Annotated[Upper | Email, Form()],
|
|
):
|
|
return [left, right]
|
|
|
|
client = TestClient(app)
|
|
response = client.post("/", data={"left": "lower", "right": "upper", "count": "5"})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == [
|
|
{"left": "lower", "count": 5},
|
|
{"right": "upper", "count": 5},
|
|
]
|
|
assert calls == [{"left": "lower", "right": "upper", "count": "5"}]
|
|
for count, branch in [("0", "Lower"), ("11", "Upper")]:
|
|
response = client.post(
|
|
"/", data={"left": "lower", "right": "upper", "count": count}
|
|
)
|
|
assert response.status_code == 422
|
|
assert response.json()["detail"][0]["loc"] == ["body", branch, "count"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"extra,expected,expected_repeated",
|
|
[
|
|
("ignore", None, None),
|
|
(
|
|
"allow",
|
|
{"count": 1, "token": "secret"},
|
|
{"count": 1, "token": "secret", "unknown": ["a", "b"]},
|
|
),
|
|
],
|
|
)
|
|
def test_unknown_fields_ignore_or_allow(extra, expected, expected_repeated):
|
|
app = FastAPI()
|
|
|
|
class Count(BaseModel):
|
|
model_config = ConfigDict(extra=extra)
|
|
count: int = 1
|
|
|
|
class Label(BaseModel):
|
|
model_config = ConfigDict(extra=extra)
|
|
label: str = "default"
|
|
|
|
@app.post("/")
|
|
def submit(
|
|
token: Annotated[str, Form()],
|
|
data: Annotated[Count | Label | None, Form(union_mode="left_to_right")] = None,
|
|
):
|
|
return data
|
|
|
|
client = TestClient(app)
|
|
response = client.post("/", data={"token": "secret"})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == expected
|
|
response = client.post("/", data={"token": "secret", "unknown": ["a", "b"]})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == expected_repeated
|
|
|
|
|
|
def test_optional_union_with_mixed_extra_configurations():
|
|
app = FastAPI()
|
|
|
|
class Named(BaseModel):
|
|
name: str
|
|
|
|
class Count(BaseModel):
|
|
model_config = ConfigDict(extra="allow")
|
|
count: int = 1
|
|
|
|
@app.post("/")
|
|
def submit(
|
|
token: Annotated[str, Form()],
|
|
data: Annotated[Named | Count | None, Form()] = None,
|
|
):
|
|
return data
|
|
|
|
response = TestClient(app).post("/", data={"token": "secret"})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"count": 1, "token": "secret"}
|
|
schema = app.openapi()["components"]["schemas"]["Body_submit__post"]
|
|
assert schema["allOf"][0]["anyOf"][0] == {"maxProperties": 0}
|
|
|
|
|
|
def test_json_and_original_model_schemas_are_unchanged():
|
|
response = client.post(
|
|
"/json",
|
|
json={"contact": {"contact_type": "person", "name": "Rick"}, "token": "secret"},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["enabled"] is False
|
|
schemas = app.openapi()["components"]["schemas"]
|
|
assert set(schemas["Person"]["properties"]) == {
|
|
"contact_type",
|
|
"name",
|
|
"tags",
|
|
"enabled",
|
|
}
|
|
assert "additionalProperties" not in schemas["Person"]
|
|
assert "token" not in Person.model_fields
|
|
|
|
|
|
def test_openapi_multiple_unions():
|
|
document = app.openapi()
|
|
body = document["paths"]["/combined"]["post"]["requestBody"]
|
|
assert body["required"] is True
|
|
schemas = document["components"]["schemas"]
|
|
schema = schemas["Body_combined_combined_post"]
|
|
assert schema["required"] == ["token"]
|
|
assert set(schema["properties"]) == {"token"}
|
|
assert len(schema["allOf"]) == 3
|
|
contact, delivery, settings = schema["allOf"]
|
|
assert contact["discriminator"]["mapping"] == {
|
|
"person": "#/components/schemas/Person",
|
|
"company": "#/components/schemas/Company",
|
|
}
|
|
assert len(contact["oneOf"]) == len(delivery["oneOf"]) == 2
|
|
assert settings["$ref"] == "#/components/schemas/Settings"
|
|
person = schemas[contact["oneOf"][0]["$ref"].rsplit("/", 1)[1]]
|
|
assert person["required"] == ["contact_type", "name"]
|
|
assert "token" not in person["properties"]
|
|
assert "location" not in person["properties"]
|
|
assert "company_name" not in person["properties"]
|
|
multipart = document["paths"]["/files"]["post"]["requestBody"]
|
|
assert set(multipart["content"]) == {"multipart/form-data"}
|
|
assert schemas["Body_files_files_post"]["required"] == ["avatar"]
|
|
|
|
|
|
def test_openapi_optional_unions():
|
|
schema = app.openapi()["components"]["schemas"]["Body_optional_optional_post"]
|
|
assert schema["required"] == ["token"]
|
|
assert len(schema["allOf"]) == 2
|
|
contact, delivery = schema["allOf"]
|
|
absent, present = contact["anyOf"]
|
|
assert {item["required"][0] for item in absent["not"]["anyOf"]} == {
|
|
"contact_type",
|
|
"name",
|
|
"tags",
|
|
"enabled",
|
|
"company_name",
|
|
}
|
|
assert "null" not in str(present)
|
|
assert len(present["anyOf"][0]["oneOf"]) == 2
|
|
assert {item["required"][0] for item in delivery["anyOf"][0]["not"]["anyOf"]} == {
|
|
"delivery",
|
|
"address",
|
|
"location",
|
|
}
|