Files
fastapi/tests/test_multiple_parameter_models.py
T

418 lines
14 KiB
Python

from typing import Annotated
from unittest.mock import patch
import pytest
from fastapi import APIRouter, Cookie, Depends, FastAPI, Header, Query, WebSocket
from fastapi.testclient import TestClient
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, Json, model_validator
@pytest.mark.parametrize(
"marker, location", [(Query, "query"), (Header, "header"), (Cookie, "cookie")]
)
@pytest.mark.parametrize("in_dependency", [False, True])
@pytest.mark.parametrize("extra", ["forbid", "allow"])
def test_composed_parameter_models(marker, location, in_dependency, extra):
app = FastAPI()
class Filters(BaseModel):
model_config = ConfigDict(extra=extra)
term: str
class Pagination(BaseModel):
model_config = ConfigDict(extra=extra)
limit: int = Field(10, ge=1, description="Page size")
def filters_dependency(filters: Annotated[Filters, marker()]):
return filters
filter_annotation = Depends(filters_dependency) if in_dependency else marker()
@app.get("/")
def read(
filters: Annotated[Filters, filter_annotation],
pagination: Annotated[Pagination, marker()],
host: Annotated[str, marker()] = "testserver",
):
return {"filters": filters, "pagination": pagination, "host": host}
client = TestClient(app)
client.headers.clear()
def send(data):
if location == "query":
return client.get("/", params=data)
if location == "header":
return client.get("/", headers=data)
return client.get(
"/", headers={"cookie": "; ".join(f"{k}={v}" for k, v in data.items())}
)
response = send({"term": "hello", "limit": "2", "host": "example.com"})
assert response.status_code == 200, response.text
assert response.json() == {
"filters": {"term": "hello"},
"pagination": {"limit": 2},
"host": "example.com",
}
response = send({"term": "hello"})
assert response.status_code == 200, response.text
assert response.json()["pagination"] == {"limit": 10}
response = send({"term": "hello", "limit": "0"})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == [location, "limit"]
response = send({"term": "hello", "typo": "unexpected"})
if extra == "forbid":
assert response.status_code == 422
assert all(
error["loc"] == [location, "typo"] for error in response.json()["detail"]
)
else:
assert response.status_code == 200, response.text
assert response.json() == {
"filters": {"term": "hello", "typo": "unexpected"},
"pagination": {"limit": 10, "typo": "unexpected"},
"host": "testserver",
}
parameters = app.openapi()["paths"]["/"]["get"]["parameters"]
assert {(p["in"], p["name"]) for p in parameters} == {
(location, "term"),
(location, "limit"),
(location, "host"),
}
limit = next(p for p in parameters if p["name"] == "limit")
assert limit["schema"]["minimum"] == 1
assert limit["schema"]["default"] == 10
assert limit["description"] == "Page size"
assert limit["required"] is False
def test_parameter_shapes_and_aliases_are_independent():
app = FastAPI()
class Many(BaseModel):
names: Annotated[
list[str], Field(validation_alias=AliasChoices("names", "legacy"))
]
class One(BaseModel):
text: str = Field(alias="legacy")
@app.get("/")
def read(many: Annotated[Many, Query()], one: Annotated[One, Query()], legacy: str):
return {"many": many, "one": one, "last": legacy}
response = TestClient(app).get(
"/", params=[("legacy", "first"), ("legacy", "last")]
)
assert response.status_code == 200, response.text
assert response.json() == {
"many": {"names": ["first", "last"]},
"one": {"legacy": "last"},
"last": "last",
}
def test_header_conversion_is_per_model_and_does_not_mutate_cached_fields():
app = FastAPI()
class Headers(BaseModel):
x_id: int
tags: list[str] = Field(default_factory=list, alias="X-Tags")
@app.get("/mixed")
def read(
converted: Annotated[Headers, Header()],
literal: Annotated[Headers, Header(convert_underscores=False)],
):
return {"converted": converted, "literal": literal}
@app.get("/query")
def read_query(data: Annotated[Headers, Query()]):
return data
client = TestClient(app)
response = client.get(
"/mixed",
headers=[("X-Id", "1"), ("x_id", "2"), ("X-Tags", "a"), ("x-tags", "b")],
)
assert response.status_code == 200, response.text
assert response.json() == {
"converted": {"x_id": 1, "X-Tags": ["a", "b"]},
"literal": {"x_id": 2, "X-Tags": ["a", "b"]},
}
schema = app.openapi()
assert {p["name"] for p in schema["paths"]["/mixed"]["get"]["parameters"]} == {
"x-id",
"x_id",
"X-Tags",
}
assert {p["name"] for p in schema["paths"]["/query"]["get"]["parameters"]} == {
"x_id",
"X-Tags",
}
response = client.get("/query", params={"x_id": "3", "X-Tags": ["c", "d"]})
assert response.status_code == 200, response.text
assert response.json() == {"x_id": 3, "X-Tags": ["c", "d"]}
response = client.get("/mixed", headers={"x-id": "bad", "x_id": "also-bad"})
assert response.status_code == 422
assert [error["loc"] for error in response.json()["detail"]] == [
["header", "x-id"],
["header", "x_id"],
]
assert Headers.model_fields["x_id"].alias is None
def test_model_validators_defaults_and_unknown_extras():
app = FastAPI()
class Range(BaseModel):
model_config = ConfigDict(extra="allow")
start: int = 0
end: int = 10
tags: list[str] = Field(default_factory=list)
@model_validator(mode="after")
def check_range(self):
if self.start > self.end:
raise ValueError("start must not exceed end")
return self
@app.get("/")
def read(data: Annotated[Range, Query()], order: str = "asc"):
return {
"data": data,
"fields_set": sorted(data.model_fields_set),
"order": order,
}
client = TestClient(app)
response = client.get("/", params={"order": "desc", "note": ["a", "b"]})
assert response.status_code == 200, response.text
assert response.json() == {
"data": {"start": 0, "end": 10, "tags": [], "note": ["a", "b"]},
"fields_set": ["note"],
"order": "desc",
}
response = client.get("/", params={"start": "20"})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["query"]
def test_dependency_overrides_and_router_parameters():
app = FastAPI()
class Filters(BaseModel):
model_config = ConfigDict(extra="forbid")
term: str
def pagination(limit: int = 10):
return limit
def replacement(page: int = 1):
return page
def token(token: str):
return token
router = APIRouter(dependencies=[Depends(token)])
@router.get("/")
def read(
filters: Annotated[Filters, Query()],
page: Annotated[int, Depends(pagination)],
):
return {"filters": filters, "page": page}
app.include_router(router)
client = TestClient(app)
assert (
client.get(
"/", params={"term": "a", "limit": "2", "token": "secret"}
).status_code
== 200
)
with patch(
"fastapi.dependencies.utils._get_request_param_names",
side_effect=AssertionError("Parameter names should already be collected"),
):
response = client.get(
"/", params={"term": "a", "limit": "2", "token": "secret"}
)
assert response.status_code == 200, response.text
assert response.json() == {"filters": {"term": "a"}, "page": 2}
app.dependency_overrides[pagination] = replacement
response = client.get("/", params={"term": "a", "page": "3", "token": "secret"})
assert response.status_code == 200, response.text
assert response.json() == {"filters": {"term": "a"}, "page": 3}
response = client.get("/", params={"term": "a", "limit": "2", "token": "secret"})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["query", "limit"]
app.dependency_overrides.clear()
response = client.get("/", params={"term": "a", "limit": "4", "token": "secret"})
assert response.status_code == 200, response.text
assert response.json() == {"filters": {"term": "a"}, "page": 4}
response = client.get("/", params={"term": "a", "page": "3", "token": "secret"})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["query", "page"]
def test_parameter_models_keep_request_locations_separate():
app = FastAPI()
class Model(BaseModel):
value: int
@app.get("/")
def read(
query: Annotated[Model, Query()],
header: Annotated[Model, Header()],
cookie: Annotated[Model, Cookie()],
):
return {"query": query, "header": header, "cookie": cookie}
response = TestClient(app).get(
"/", params={"value": "1"}, headers={"value": "2", "cookie": "value=3"}
)
assert response.status_code == 200, response.text
assert response.json() == {
"query": {"value": 1},
"header": {"value": 2},
"cookie": {"value": 3},
}
parameters = app.openapi()["paths"]["/"]["get"]["parameters"]
assert {(param["in"], param["name"]) for param in parameters} == {
("query", "value"),
("header", "value"),
("cookie", "value"),
}
@pytest.mark.parametrize("marker", [Header, Cookie])
def test_other_locations_do_not_allow_extra_query_parameters(marker):
app = FastAPI()
class Filters(BaseModel):
model_config = ConfigDict(extra="forbid")
term: str
@app.get("/")
def read(
filters: Annotated[Filters, Query()], limit: Annotated[int, marker()] = 10
):
return filters
client = TestClient(app)
response = client.get("/", params={"term": "a"})
assert response.status_code == 200, response.text
assert response.json() == {"term": "a"}
response = client.get("/", params={"term": "a", "limit": "2"})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["query", "limit"]
assert response.json()["detail"][0]["type"] == "extra_forbidden"
def test_hidden_model_and_shared_required_parameter():
app = FastAPI()
class Optional(BaseModel):
shared: int = 1
class Required(BaseModel):
shared: int
class Hidden(BaseModel):
secret: str
@app.get("/")
def read(
optional: Annotated[Optional, Query()],
required: Annotated[Required, Query()],
hidden: Annotated[Hidden, Query(include_in_schema=False)],
):
return [optional, required, hidden]
client = TestClient(app)
response = client.get("/", params={"shared": "2", "secret": "ok"})
assert response.status_code == 200, response.text
assert response.json() == [{"shared": 2}, {"shared": 2}, {"secret": "ok"}]
assert client.get("/", params={"secret": "ok"}).status_code == 422
parameters = app.openapi()["paths"]["/"]["get"]["parameters"]
assert len(parameters) == 1
assert parameters[0]["name"] == "shared"
assert parameters[0]["required"] is True
def test_shared_alias_choices_still_validate_unused_choices_as_extras():
app = FastAPI()
class Model(BaseModel):
model_config = ConfigDict(extra="forbid")
value: Annotated[int, Field(validation_alias=AliasChoices("first", "second"))]
@app.get("/")
def read(model: Annotated[Model, Query()], second: int = 0):
return model
client = TestClient(app)
assert client.get("/", params={"second": "2"}).json() == {"value": 2}
response = client.get("/", params={"first": "1", "second": "2"})
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["query", "second"]
@pytest.mark.parametrize(
"marker, location", [(Query, "query"), (Header, "header"), (Cookie, "cookie")]
)
def test_explicit_json_model_stays_a_single_parameter(marker, location):
app = FastAPI()
class Model(BaseModel):
value: int
@app.get("/")
def read(
data: Annotated[Json[Model], marker()], limit: Annotated[int, marker()] = 10
):
return {"data": data, "limit": limit}
client = TestClient(app)
data = '{"value": 2}'
if location == "query":
response = client.get("/", params={"data": data})
elif location == "header":
response = client.get("/", headers={"data": data})
else:
response = client.get("/", headers={"cookie": f"data={data}"})
assert response.status_code == 200, response.text
assert response.json() == {"data": {"value": 2}, "limit": 10}
assert {p["name"] for p in app.openapi()["paths"]["/"]["get"]["parameters"]} == {
"data",
"limit",
}
def test_nested_dependencies_and_websocket_parameter_models():
app = FastAPI()
class Model(BaseModel):
model_config = ConfigDict(extra="forbid")
term: str
def filters(data: Annotated[Model, Query()]):
return data
def nested(data: Annotated[Model, Depends(filters)]):
return data
@app.websocket("/ws")
async def read(
websocket: WebSocket, data: Annotated[Model, Depends(nested)], limit: int = 10
):
await websocket.accept()
await websocket.send_json({"term": data.term, "limit": limit})
await websocket.close()
with TestClient(app).websocket_connect("/ws?term=a&limit=2") as websocket:
assert websocket.receive_json() == {"term": "a", "limit": 2}