You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

280 lines
8.4 KiB

from typing import Annotated, Any
import pytest
from fastapi import (
Body,
Depends,
FastAPI,
File,
Form,
Header,
Query,
Security,
status,
)
from fastapi.exceptions import FastAPIError
from fastapi.security import HTTPBearer
from fastapi.testclient import TestClient
from tests._annotated_body_depends_merge_common import (
BarPayload,
BasePayload,
FooPayload,
openapi_request_body_schema_ref,
)
_MERGED_BODY_DEFAULT = FooPayload(kind="foo", extra_foo="default")
class TestAnnotatedBodyDependsMergeBody:
@pytest.mark.parametrize(
(
"path",
"ann1",
"ann2",
"model_cls",
"expected_ref_suffix",
"assert_no_query_params",
),
[
("/a", Body(), Depends(FooPayload), FooPayload, "FooPayload", True),
("/b", Depends(BarPayload), Body(), BarPayload, "BarPayload", False),
],
)
def test_openapi_json_body_depends_merge(
self,
path: str,
ann1: Any,
ann2: Any,
model_cls: type[BasePayload],
expected_ref_suffix: str,
assert_no_query_params: bool,
) -> None:
app = FastAPI()
@app.post(path)
def route(
data: Annotated[BasePayload, ann1, ann2],
) -> None:
assert isinstance(data, model_cls)
client = TestClient(app)
schema = client.get("/openapi.json").json()
post = schema["paths"][path]["post"]
if assert_no_query_params:
assert post.get("parameters") in (None, [])
ref = openapi_request_body_schema_ref(
schema, path=path, method="post", content_type="application/json"
)
assert ref.endswith(f"/{expected_ref_suffix}")
def test_runtime_json_validates_concrete_model(self) -> None:
app = FastAPI()
@app.post("/c")
def route(
data: Annotated[BasePayload, Body(), Depends(FooPayload)],
) -> dict[str, str]:
return {"extra": data.extra_foo}
client = TestClient(app)
r = client.post("/c", json={"kind": "foo", "extra_foo": "x"})
assert r.status_code == status.HTTP_200_OK
assert r.json() == {"extra": "x"}
bad = client.post("/c", json={"kind": "foo"})
assert bad.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
def test_merge_depends_empty_falls_back_to_declared_model(self) -> None:
app = FastAPI()
@app.post("/depends-empty")
def route(
data: Annotated[FooPayload, Body(), Depends()],
) -> dict[str, str]:
return {"extra": data.extra_foo}
client = TestClient(app)
r = client.post("/depends-empty", json={"kind": "foo", "extra_foo": "z"})
assert r.status_code == status.HTTP_200_OK
assert r.json() == {"extra": "z"}
def test_merged_body_parameter_signature_default(self) -> None:
app = FastAPI()
@app.post("/merged-default")
def route(
data: Annotated[
BasePayload, Body(), Depends(FooPayload)
] = _MERGED_BODY_DEFAULT,
) -> dict[str, str]:
return {"extra": data.extra_foo}
client = TestClient(app)
empty = client.post("/merged-default")
assert empty.status_code == status.HTTP_200_OK
assert empty.json() == {"extra": "default"}
overridden = client.post(
"/merged-default", json={"kind": "foo", "extra_foo": "ov"}
)
assert overridden.status_code == status.HTTP_200_OK
assert overridden.json() == {"extra": "ov"}
def test_put_patch_json_body_depends_openapi(self) -> None:
app = FastAPI()
path = "/items/{item_id}"
@app.put(path)
def route_put(
item_id: str,
data: Annotated[BasePayload, Body(), Depends(FooPayload)],
) -> None:
assert isinstance(data, FooPayload)
@app.patch(path)
def route_patch(
item_id: str,
data: Annotated[BasePayload, Depends(FooPayload), Body()],
) -> None:
assert isinstance(data, FooPayload)
client = TestClient(app)
schema = client.get("/openapi.json").json()
for method in ("put", "patch"):
ref = openapi_request_body_schema_ref(
schema, path=path, method=method, content_type="application/json"
)
assert ref.endswith("/FooPayload")
r = client.put("/items/1", json={"kind": "foo", "extra_foo": "a"})
assert r.status_code == status.HTTP_200_OK
r2 = client.patch("/items/1", json={"kind": "foo", "extra_foo": "b"})
assert r2.status_code == status.HTTP_200_OK
def test_rejects_body_with_callable_depends(self) -> None:
app = FastAPI()
def not_a_model() -> None:
return None
with pytest.raises(FastAPIError, match="Pydantic model class"):
@app.post("/d")
def route_d(
data: Annotated[BasePayload, Body(), Depends(not_a_model)],
) -> None:
pass # pragma: no cover
def test_rejects_multiple_depends_with_body(self) -> None:
app = FastAPI()
with pytest.raises(FastAPIError, match="multiple `Depends`"):
@app.post("/e")
def route_e(
data: Annotated[
BasePayload,
Body(),
Depends(FooPayload),
Depends(BarPayload),
],
) -> None:
pass # pragma: no cover
def test_rejects_body_and_form_together(self) -> None:
app = FastAPI()
with pytest.raises(FastAPIError, match="multiple `Body`"):
@app.post("/conflict")
def route_conflict(
data: Annotated[
BasePayload,
Body(),
Form(),
Depends(FooPayload),
],
) -> None:
pass # pragma: no cover
@pytest.mark.parametrize(
"shape_marker",
[
pytest.param(Body(), id="body"),
pytest.param(Form(), id="form"),
pytest.param(File(), id="file"),
],
)
def test_rejects_query_with_body_like_in_same_annotated(
self, shape_marker: Any
) -> None:
app = FastAPI()
with pytest.raises(FastAPIError, match="Cannot combine `Query`"):
@app.post("/query-shape")
def route_bad(
data: Annotated[
BasePayload,
shape_marker,
Query(),
Depends(FooPayload),
],
) -> None:
pass # pragma: no cover
def test_rejects_multiple_query_with_depends(self) -> None:
app = FastAPI()
with pytest.raises(FastAPIError, match="multiple `Query`"):
@app.post("/multi-query")
def route_multi_query(
data: Annotated[
BasePayload,
Query(),
Query(),
Depends(FooPayload),
],
) -> None:
pass # pragma: no cover
def test_rejects_body_like_with_other_param_in_same_annotated(self) -> None:
app = FastAPI()
with pytest.raises(FastAPIError, match="other parameter types"):
@app.post("/body-plus-header")
def route_body_header(
data: Annotated[
BasePayload,
Body(),
Header(),
Depends(FooPayload),
],
) -> None:
pass # pragma: no cover
def test_rejects_security_with_body_like_merge(self) -> None:
app = FastAPI()
bearer = HTTPBearer(auto_error=False)
with pytest.raises(FastAPIError, match="`Security`"):
@app.post("/body-security")
def route_body_security(
data: Annotated[BasePayload, Body(), Security(bearer)],
) -> None:
pass # pragma: no cover
def test_rejects_merge_on_path_parameter(self) -> None:
app = FastAPI()
with pytest.raises(FastAPIError, match="path parameter"):
@app.post("/path/{data}")
def route_path(
data: Annotated[BasePayload, Body(), Depends(FooPayload)],
) -> None:
pass # pragma: no cover