mirror of
https://github.com/fastapi/fastapi.git
synced 2026-10-28 18:59:08 +08:00
485 lines
16 KiB
Python
485 lines
16 KiB
Python
from typing import Annotated
|
|
|
|
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
|
|
|
|
|
|
def get_with_parameters(client, path, location, parameters):
|
|
if location == "query":
|
|
return client.get(path, params=parameters)
|
|
if location == "header":
|
|
return client.get(path, headers=parameters)
|
|
return client.get(
|
|
path,
|
|
headers={
|
|
"cookie": "; ".join(f"{key}={value}" for key, value in parameters.items())
|
|
},
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"marker, location", [(Query, "query"), (Header, "header"), (Cookie, "cookie")]
|
|
)
|
|
@pytest.mark.parametrize("path", ["/", "/dependency"])
|
|
def test_composed_parameter_models_allow_extras(marker, location, path):
|
|
app = FastAPI()
|
|
|
|
class Filters(BaseModel):
|
|
model_config = ConfigDict(extra="allow")
|
|
term: str
|
|
|
|
class Pagination(BaseModel):
|
|
model_config = ConfigDict(extra="allow")
|
|
limit: int = Field(10, ge=1, description="Page size")
|
|
|
|
@app.get("/")
|
|
def read(
|
|
filters: Annotated[Filters, marker()],
|
|
pagination: Annotated[Pagination, marker()],
|
|
host: Annotated[str, marker()] = "testserver",
|
|
):
|
|
return {"filters": filters, "pagination": pagination, "host": host}
|
|
|
|
def filters_dependency(filters: Annotated[Filters, marker()]):
|
|
return filters
|
|
|
|
@app.get("/dependency")
|
|
def read_dependency(
|
|
filters: Annotated[Filters, Depends(filters_dependency)],
|
|
pagination: Annotated[Pagination, marker()],
|
|
host: Annotated[str, marker()] = "testserver",
|
|
):
|
|
return {"filters": filters, "pagination": pagination, "host": host}
|
|
|
|
client = TestClient(app)
|
|
client.headers.clear()
|
|
response = get_with_parameters(
|
|
client, path, location, {"term": "hello", "limit": "2", "host": "example.com"}
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {
|
|
"filters": {"term": "hello", "limit": "2", "host": "example.com"},
|
|
"pagination": {"limit": 2, "term": "hello", "host": "example.com"},
|
|
"host": "example.com",
|
|
}
|
|
response = get_with_parameters(client, path, location, {"term": "hello"})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["pagination"]["limit"] == 10
|
|
assert response.json()["pagination"]["term"] == "hello"
|
|
assert response.json()["host"] == "testserver"
|
|
response = get_with_parameters(
|
|
client, path, location, {"term": "hello", "limit": "0"}
|
|
)
|
|
assert response.status_code == 422
|
|
assert response.json()["detail"][0]["loc"] == [location, "limit"]
|
|
response = get_with_parameters(
|
|
client,
|
|
path,
|
|
location,
|
|
{"term": "hello", "typo": "unexpected", "host": "example.com"},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {
|
|
"filters": {"term": "hello", "typo": "unexpected", "host": "example.com"},
|
|
"pagination": {
|
|
"limit": 10,
|
|
"term": "hello",
|
|
"typo": "unexpected",
|
|
"host": "example.com",
|
|
},
|
|
"host": "example.com",
|
|
}
|
|
parameters = app.openapi()["paths"][path]["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
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"marker, location", [(Query, "query"), (Header, "header"), (Cookie, "cookie")]
|
|
)
|
|
@pytest.mark.parametrize("path", ["/", "/dependency"])
|
|
def test_composed_parameter_models_forbid_extras(marker, location, path):
|
|
app = FastAPI()
|
|
|
|
class Filters(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
term: str
|
|
|
|
class Pagination(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
limit: int = 10
|
|
|
|
@app.get("/")
|
|
def read(
|
|
filters: Annotated[Filters, marker()],
|
|
pagination: Annotated[Pagination, marker()],
|
|
host: Annotated[str, marker()] = "testserver",
|
|
):
|
|
pytest.fail("Invalid parameters reached the endpoint") # pragma: no cover
|
|
|
|
def filters_dependency(filters: Annotated[Filters, marker()]):
|
|
pytest.fail("Invalid parameters reached the dependency") # pragma: no cover
|
|
|
|
@app.get("/dependency")
|
|
def read_dependency(
|
|
filters: Annotated[Filters, Depends(filters_dependency)],
|
|
pagination: Annotated[Pagination, marker()],
|
|
host: Annotated[str, marker()] = "testserver",
|
|
):
|
|
pytest.fail("Invalid parameters reached the endpoint") # pragma: no cover
|
|
|
|
client = TestClient(app)
|
|
client.headers.clear()
|
|
response = get_with_parameters(
|
|
client, path, location, {"term": "hello", "limit": "2", "host": "example.com"}
|
|
)
|
|
assert response.status_code == 422
|
|
assert {error["loc"][-1] for error in response.json()["detail"]} == {
|
|
"limit",
|
|
"host",
|
|
"term",
|
|
}
|
|
assert all(
|
|
error["type"] == "extra_forbidden" for error in response.json()["detail"]
|
|
)
|
|
|
|
|
|
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"],
|
|
"order": "desc",
|
|
},
|
|
"fields_set": ["note", "order"],
|
|
"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 = "secret"):
|
|
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)
|
|
response = client.get("/", params={"term": "a"})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"filters": {"term": "a"}, "page": 10}
|
|
response = client.get("/", params={"term": "a", "limit": "2", "token": "secret"})
|
|
assert response.status_code == 422
|
|
assert [error["loc"] for error in response.json()["detail"]] == [
|
|
["query", "limit"],
|
|
["query", "token"],
|
|
]
|
|
assert all(
|
|
error["type"] == "extra_forbidden" for error in response.json()["detail"]
|
|
)
|
|
app.dependency_overrides[pagination] = replacement
|
|
response = client.get("/", params={"term": "a", "page": "3", "token": "secret"})
|
|
assert response.status_code == 422
|
|
assert [error["loc"] for error in response.json()["detail"]] == [
|
|
["query", "page"],
|
|
["query", "token"],
|
|
]
|
|
assert all(
|
|
error["type"] == "extra_forbidden" for error in response.json()["detail"]
|
|
)
|
|
|
|
|
|
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
|
|
limit: int = 10
|
|
|
|
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}
|