18 changed files with 1038 additions and 2 deletions
@ -0,0 +1,53 @@ |
|||||
|
from typing import Annotated |
||||
|
|
||||
|
from fastapi import APIRouter, Body, Depends, FastAPI |
||||
|
from pydantic import BaseModel |
||||
|
|
||||
|
app = FastAPI() |
||||
|
items_router = APIRouter() |
||||
|
|
||||
|
|
||||
|
class ItemBase(BaseModel): |
||||
|
name: str |
||||
|
|
||||
|
|
||||
|
class Gadget(ItemBase): |
||||
|
description: str |
||||
|
|
||||
|
|
||||
|
class Part(ItemBase): |
||||
|
sku: str |
||||
|
|
||||
|
|
||||
|
def register_post_route( |
||||
|
router: APIRouter, |
||||
|
path: str, |
||||
|
schema: type[ItemBase], |
||||
|
): |
||||
|
@router.post(path) |
||||
|
def create_entity( |
||||
|
entity: Annotated[ |
||||
|
ItemBase, |
||||
|
Body(), |
||||
|
Depends(schema), |
||||
|
], |
||||
|
): |
||||
|
return entity |
||||
|
|
||||
|
return create_entity |
||||
|
|
||||
|
|
||||
|
register_post_route( |
||||
|
items_router, |
||||
|
"/objects/gadgets/", |
||||
|
Gadget, |
||||
|
) |
||||
|
register_post_route( |
||||
|
items_router, |
||||
|
"/objects/parts/", |
||||
|
Part, |
||||
|
) |
||||
|
app.include_router( |
||||
|
items_router, |
||||
|
prefix="/items", |
||||
|
) |
||||
@ -0,0 +1,57 @@ |
|||||
|
from typing import Annotated, Any |
||||
|
|
||||
|
from fastapi import APIRouter, Depends, FastAPI, File, UploadFile |
||||
|
from pydantic import BaseModel |
||||
|
|
||||
|
app = FastAPI() |
||||
|
files_router = APIRouter() |
||||
|
|
||||
|
|
||||
|
class AttachmentBase(BaseModel): |
||||
|
file: UploadFile |
||||
|
|
||||
|
|
||||
|
class CommentedAttachment(AttachmentBase): |
||||
|
comment: str = "" |
||||
|
|
||||
|
|
||||
|
class NamedAttachment(AttachmentBase): |
||||
|
name: str = "" |
||||
|
|
||||
|
|
||||
|
def register_upload_route( |
||||
|
router: APIRouter, |
||||
|
path: str, |
||||
|
schema: type[AttachmentBase], |
||||
|
): |
||||
|
@router.post(path) |
||||
|
async def upload( |
||||
|
attachment: Annotated[ |
||||
|
AttachmentBase, |
||||
|
File(), |
||||
|
Depends(schema), |
||||
|
], |
||||
|
) -> dict[str, Any]: |
||||
|
return { |
||||
|
"filename": attachment.file.filename, |
||||
|
"content_type": attachment.file.content_type, |
||||
|
"data": attachment.model_dump(exclude={"file"}), |
||||
|
} |
||||
|
|
||||
|
return upload |
||||
|
|
||||
|
|
||||
|
register_upload_route( |
||||
|
files_router, |
||||
|
"/attachments/commented/", |
||||
|
CommentedAttachment, |
||||
|
) |
||||
|
register_upload_route( |
||||
|
files_router, |
||||
|
"/attachments/named/", |
||||
|
NamedAttachment, |
||||
|
) |
||||
|
app.include_router( |
||||
|
files_router, |
||||
|
prefix="/files", |
||||
|
) |
||||
@ -0,0 +1,53 @@ |
|||||
|
from typing import Annotated |
||||
|
|
||||
|
from fastapi import APIRouter, Depends, FastAPI, Form |
||||
|
from pydantic import BaseModel |
||||
|
|
||||
|
app = FastAPI() |
||||
|
auth_router = APIRouter() |
||||
|
|
||||
|
|
||||
|
class AccountBase(BaseModel): |
||||
|
username: str |
||||
|
|
||||
|
|
||||
|
class PasswordLogin(AccountBase): |
||||
|
password: str |
||||
|
|
||||
|
|
||||
|
class TokenLogin(AccountBase): |
||||
|
token: str |
||||
|
|
||||
|
|
||||
|
def register_form_post( |
||||
|
router: APIRouter, |
||||
|
path: str, |
||||
|
schema: type[AccountBase], |
||||
|
): |
||||
|
@router.post(path) |
||||
|
def authenticate( |
||||
|
data: Annotated[ |
||||
|
AccountBase, |
||||
|
Form(), |
||||
|
Depends(schema), |
||||
|
], |
||||
|
): |
||||
|
return data |
||||
|
|
||||
|
return authenticate |
||||
|
|
||||
|
|
||||
|
register_form_post( |
||||
|
auth_router, |
||||
|
"/session/password/", |
||||
|
PasswordLogin, |
||||
|
) |
||||
|
register_form_post( |
||||
|
auth_router, |
||||
|
"/session/token/", |
||||
|
TokenLogin, |
||||
|
) |
||||
|
app.include_router( |
||||
|
auth_router, |
||||
|
prefix="/auth", |
||||
|
) |
||||
@ -0,0 +1,59 @@ |
|||||
|
from typing import Annotated |
||||
|
|
||||
|
from fastapi import APIRouter, Depends, FastAPI, Query |
||||
|
from pydantic import BaseModel |
||||
|
|
||||
|
app = FastAPI() |
||||
|
catalog_router = APIRouter() |
||||
|
|
||||
|
|
||||
|
class ProductFiltersBase(BaseModel): |
||||
|
category: str | None = None |
||||
|
|
||||
|
|
||||
|
class ProductFiltersFull(ProductFiltersBase): |
||||
|
in_stock: bool = True |
||||
|
|
||||
|
|
||||
|
class ProductFiltersPaginated(ProductFiltersBase): |
||||
|
page: int = 1 |
||||
|
per_page: int = 10 |
||||
|
|
||||
|
|
||||
|
def register_product_list( |
||||
|
router: APIRouter, |
||||
|
path: str, |
||||
|
schema: type[ProductFiltersBase], |
||||
|
): |
||||
|
@router.get(path) |
||||
|
def list_products( |
||||
|
params: Annotated[ |
||||
|
ProductFiltersBase, |
||||
|
Query(), # optional |
||||
|
Depends(schema), |
||||
|
], |
||||
|
): |
||||
|
return params |
||||
|
|
||||
|
return list_products |
||||
|
|
||||
|
|
||||
|
register_product_list( |
||||
|
catalog_router, |
||||
|
"/items/", |
||||
|
ProductFiltersFull, |
||||
|
) |
||||
|
register_product_list( |
||||
|
catalog_router, |
||||
|
"/items-paginated/", |
||||
|
ProductFiltersPaginated, |
||||
|
) |
||||
|
register_product_list( |
||||
|
catalog_router, |
||||
|
"/basics/", |
||||
|
ProductFiltersBase, |
||||
|
) |
||||
|
app.include_router( |
||||
|
catalog_router, |
||||
|
prefix="/catalog", |
||||
|
) |
||||
@ -0,0 +1,77 @@ |
|||||
|
from typing import Annotated |
||||
|
|
||||
|
from fastapi import APIRouter, Body, Depends, FastAPI, Query |
||||
|
from pydantic import BaseModel |
||||
|
|
||||
|
app = FastAPI() |
||||
|
records_router = APIRouter() |
||||
|
|
||||
|
|
||||
|
class ClientInfoBase(BaseModel): |
||||
|
client_id: str |
||||
|
|
||||
|
|
||||
|
class RetailClientInfo(ClientInfoBase): |
||||
|
region: str | None = None |
||||
|
|
||||
|
|
||||
|
class PartnerClientInfo(ClientInfoBase): |
||||
|
contract_ref: str |
||||
|
|
||||
|
|
||||
|
class RecordBase(BaseModel): |
||||
|
title: str |
||||
|
|
||||
|
|
||||
|
class CaseFileRecord(RecordBase): |
||||
|
case_number: str |
||||
|
|
||||
|
|
||||
|
class ContractRecord(RecordBase): |
||||
|
contract_id: str |
||||
|
|
||||
|
|
||||
|
def register_record_route( |
||||
|
router: APIRouter, |
||||
|
path: str, |
||||
|
record_schema: type[RecordBase], |
||||
|
client_schema: type[ClientInfoBase], |
||||
|
): |
||||
|
@router.post(path) |
||||
|
def create_record( |
||||
|
client_info: Annotated[ |
||||
|
ClientInfoBase, |
||||
|
Query(), |
||||
|
Depends(client_schema), |
||||
|
], |
||||
|
record: Annotated[ |
||||
|
RecordBase, |
||||
|
Body(), |
||||
|
Depends(record_schema), |
||||
|
], |
||||
|
): |
||||
|
print(f"processing client #{client_info.client_id} data {record.title!r}") |
||||
|
return { |
||||
|
"client_info": client_info.model_dump(), |
||||
|
"record": record.model_dump(), |
||||
|
} |
||||
|
|
||||
|
return create_record |
||||
|
|
||||
|
|
||||
|
register_record_route( |
||||
|
records_router, |
||||
|
"/case-files/", |
||||
|
CaseFileRecord, |
||||
|
RetailClientInfo, |
||||
|
) |
||||
|
register_record_route( |
||||
|
records_router, |
||||
|
"/contracts/", |
||||
|
ContractRecord, |
||||
|
PartnerClientInfo, |
||||
|
) |
||||
|
app.include_router( |
||||
|
records_router, |
||||
|
prefix="/clients", |
||||
|
) |
||||
@ -0,0 +1,42 @@ |
|||||
|
from typing import Any |
||||
|
|
||||
|
from fastapi import UploadFile |
||||
|
from pydantic import BaseModel |
||||
|
|
||||
|
|
||||
|
class BasePayload(BaseModel): |
||||
|
kind: str |
||||
|
|
||||
|
|
||||
|
class FooPayload(BasePayload): |
||||
|
kind: str = "foo" |
||||
|
extra_foo: str |
||||
|
|
||||
|
|
||||
|
class BarPayload(BasePayload): |
||||
|
kind: str = "bar" |
||||
|
extra_bar: str |
||||
|
|
||||
|
|
||||
|
class FooFilePayload(BasePayload): |
||||
|
kind: str = "foo" |
||||
|
extra_foo: str |
||||
|
blob: UploadFile |
||||
|
|
||||
|
|
||||
|
class BarFilePayload(BasePayload): |
||||
|
kind: str = "bar" |
||||
|
extra_bar: str |
||||
|
blob: UploadFile |
||||
|
|
||||
|
|
||||
|
def openapi_request_body_schema_ref( |
||||
|
schema: dict[str, Any], |
||||
|
*, |
||||
|
path: str, |
||||
|
method: str = "post", |
||||
|
content_type: str, |
||||
|
) -> str: |
||||
|
return schema["paths"][path][method]["requestBody"]["content"][content_type][ |
||||
|
"schema" |
||||
|
]["$ref"] |
||||
@ -0,0 +1,24 @@ |
|||||
|
"""Load `docs_src` tutorial modules by path for smoke tests.""" |
||||
|
|
||||
|
import importlib.util |
||||
|
import sys |
||||
|
from pathlib import Path |
||||
|
|
||||
|
from fastapi.testclient import TestClient |
||||
|
|
||||
|
_DOCS_SRC = Path(__file__).resolve().parent.parent.parent / "docs_src" |
||||
|
|
||||
|
|
||||
|
def load_docs_src_module(unique_name: str, *relative_parts: str): |
||||
|
path = _DOCS_SRC.joinpath(*relative_parts) |
||||
|
spec = importlib.util.spec_from_file_location(unique_name, path) |
||||
|
assert spec and spec.loader |
||||
|
module = importlib.util.module_from_spec(spec) |
||||
|
sys.modules[unique_name] = module |
||||
|
spec.loader.exec_module(module) |
||||
|
return module |
||||
|
|
||||
|
|
||||
|
def docs_src_test_client(unique_name: str, *relative_parts: str) -> TestClient: |
||||
|
mod = load_docs_src_module(unique_name, *relative_parts) |
||||
|
return TestClient(mod.app) |
||||
@ -0,0 +1,34 @@ |
|||||
|
import pytest |
||||
|
from fastapi import status |
||||
|
|
||||
|
from tests.docs_src._loader import docs_src_test_client |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(scope="module") |
||||
|
def client(): |
||||
|
return docs_src_test_client( |
||||
|
"docs_src_body_depends_model_merge_body_tutorial001", |
||||
|
"body_depends_model_merge_body", |
||||
|
"tutorial001_an_py310.py", |
||||
|
) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.parametrize( |
||||
|
("path", "payload"), |
||||
|
[ |
||||
|
pytest.param( |
||||
|
"/items/objects/gadgets/", |
||||
|
{"name": "G1", "description": "d1"}, |
||||
|
id="gadget", |
||||
|
), |
||||
|
pytest.param( |
||||
|
"/items/objects/parts/", |
||||
|
{"name": "P1", "sku": "S1"}, |
||||
|
id="part", |
||||
|
), |
||||
|
], |
||||
|
) |
||||
|
def test_json_body_depends_model_merge(client, path, payload): |
||||
|
response = client.post(path, json=payload) |
||||
|
assert response.status_code == status.HTTP_200_OK, response.text |
||||
|
assert response.json() == payload |
||||
@ -0,0 +1,53 @@ |
|||||
|
import pytest |
||||
|
from fastapi import status |
||||
|
|
||||
|
from tests.docs_src._loader import docs_src_test_client |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(scope="module") |
||||
|
def client(): |
||||
|
return docs_src_test_client( |
||||
|
"docs_src_body_depends_model_merge_file_tutorial001", |
||||
|
"body_depends_model_merge_file", |
||||
|
"tutorial001_an_py310.py", |
||||
|
) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.parametrize( |
||||
|
("path", "form_fields", "file_name", "file_bytes", "file_ct"), |
||||
|
[ |
||||
|
pytest.param( |
||||
|
"/files/attachments/commented/", |
||||
|
{"comment": "Spec"}, |
||||
|
"doc.pdf", |
||||
|
b"%PDF-1.4\n", |
||||
|
"application/pdf", |
||||
|
id="commented", |
||||
|
), |
||||
|
pytest.param( |
||||
|
"/files/attachments/named/", |
||||
|
{"name": "My text file"}, |
||||
|
"file.txt", |
||||
|
b"hello", |
||||
|
"text/plain", |
||||
|
id="named", |
||||
|
), |
||||
|
], |
||||
|
) |
||||
|
def test_file_depends_model_merge( |
||||
|
client, |
||||
|
path, |
||||
|
form_fields, |
||||
|
file_name, |
||||
|
file_bytes, |
||||
|
file_ct, |
||||
|
): |
||||
|
upload = (file_name, file_bytes, file_ct) |
||||
|
response = client.post(path, data=form_fields, files={"file": upload}) |
||||
|
assert response.status_code == status.HTTP_200_OK, response.text |
||||
|
expected = { |
||||
|
"filename": file_name, |
||||
|
"content_type": file_ct, |
||||
|
"data": form_fields, |
||||
|
} |
||||
|
assert response.json() == expected |
||||
@ -0,0 +1,34 @@ |
|||||
|
import pytest |
||||
|
from fastapi import status |
||||
|
|
||||
|
from tests.docs_src._loader import docs_src_test_client |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(scope="module") |
||||
|
def client(): |
||||
|
return docs_src_test_client( |
||||
|
"docs_src_body_depends_model_merge_form_tutorial001", |
||||
|
"body_depends_model_merge_form", |
||||
|
"tutorial001_an_py310.py", |
||||
|
) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.parametrize( |
||||
|
("path", "form_data"), |
||||
|
[ |
||||
|
pytest.param( |
||||
|
"/auth/session/password/", |
||||
|
{"username": "alice", "password": "secret"}, |
||||
|
id="password", |
||||
|
), |
||||
|
pytest.param( |
||||
|
"/auth/session/token/", |
||||
|
{"username": "bob", "token": "tok-123"}, |
||||
|
id="token", |
||||
|
), |
||||
|
], |
||||
|
) |
||||
|
def test_form_depends_model_merge(client, path, form_data): |
||||
|
response = client.post(path, data=form_data) |
||||
|
assert response.status_code == status.HTTP_200_OK, response.text |
||||
|
assert response.json() == form_data |
||||
@ -0,0 +1,42 @@ |
|||||
|
import pytest |
||||
|
from fastapi import status |
||||
|
|
||||
|
from tests.docs_src._loader import docs_src_test_client |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(scope="module") |
||||
|
def client(): |
||||
|
return docs_src_test_client( |
||||
|
"docs_src_body_depends_model_merge_query_tutorial001", |
||||
|
"body_depends_model_merge_query", |
||||
|
"tutorial001_an_py310.py", |
||||
|
) |
||||
|
|
||||
|
|
||||
|
_CATEGORY = "books" |
||||
|
|
||||
|
|
||||
|
@pytest.mark.parametrize( |
||||
|
("path", "params"), |
||||
|
[ |
||||
|
pytest.param( |
||||
|
"/catalog/items/", |
||||
|
{"category": _CATEGORY, "in_stock": False}, |
||||
|
id="items_full", |
||||
|
), |
||||
|
pytest.param( |
||||
|
"/catalog/basics/", |
||||
|
{"category": _CATEGORY}, |
||||
|
id="basics", |
||||
|
), |
||||
|
pytest.param( |
||||
|
"/catalog/items-paginated/", |
||||
|
{"category": _CATEGORY, "page": 2, "per_page": 5}, |
||||
|
id="paginated", |
||||
|
), |
||||
|
], |
||||
|
) |
||||
|
def test_query_depends_model_merge(client, path, params): |
||||
|
response = client.get(path, params=params) |
||||
|
assert response.status_code == status.HTTP_200_OK, response.text |
||||
|
assert response.json() == params |
||||
@ -0,0 +1,39 @@ |
|||||
|
import pytest |
||||
|
from fastapi import status |
||||
|
|
||||
|
from tests.docs_src._loader import docs_src_test_client |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(scope="module") |
||||
|
def client(): |
||||
|
return docs_src_test_client( |
||||
|
"docs_src_body_depends_model_merge_query_plus_body_tutorial001", |
||||
|
"body_depends_model_merge_query_plus_body", |
||||
|
"tutorial001_an_py310.py", |
||||
|
) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.parametrize( |
||||
|
("path", "query", "json_body"), |
||||
|
[ |
||||
|
( |
||||
|
"/clients/case-files/", |
||||
|
{"client_id": "rick", "region": "west"}, |
||||
|
{"title": "Q1", "case_number": "C-9"}, |
||||
|
), |
||||
|
( |
||||
|
"/clients/contracts/", |
||||
|
{"client_id": "morty", "contract_ref": "R-9"}, |
||||
|
{"title": "Partner deal", "contract_id": "Z-1"}, |
||||
|
), |
||||
|
], |
||||
|
ids=["case_file", "contract"], |
||||
|
) |
||||
|
def test_query_plus_merged_json_body(client, path, query, json_body): |
||||
|
response = client.post(path, params=query, json=json_body) |
||||
|
assert response.status_code == status.HTTP_200_OK, response.text |
||||
|
expected = { |
||||
|
"client_info": query, |
||||
|
"record": json_body, |
||||
|
} |
||||
|
assert response.json() == expected |
||||
@ -0,0 +1,161 @@ |
|||||
|
from typing import Annotated, Any |
||||
|
|
||||
|
import pytest |
||||
|
from fastapi import Body, Depends, FastAPI, Form, status |
||||
|
from fastapi.exceptions import FastAPIError |
||||
|
from fastapi.testclient import TestClient |
||||
|
|
||||
|
from tests._annotated_body_depends_merge_common import ( |
||||
|
BarPayload, |
||||
|
BasePayload, |
||||
|
FooPayload, |
||||
|
openapi_request_body_schema_ref, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
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_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 |
||||
|
|
||||
|
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 |
||||
@ -0,0 +1,100 @@ |
|||||
|
from io import BytesIO |
||||
|
from typing import Annotated, Any |
||||
|
|
||||
|
import pytest |
||||
|
from fastapi import Depends, FastAPI, File, Form, status |
||||
|
from fastapi.exceptions import FastAPIError |
||||
|
from fastapi.testclient import TestClient |
||||
|
|
||||
|
from tests._annotated_body_depends_merge_common import ( |
||||
|
BarFilePayload, |
||||
|
BasePayload, |
||||
|
FooFilePayload, |
||||
|
FooPayload, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class TestAnnotatedBodyDependsMergeFile: |
||||
|
@pytest.mark.parametrize( |
||||
|
("path", "ann1", "ann2", "model_cls", "expected_ref_suffix"), |
||||
|
[ |
||||
|
( |
||||
|
"/file-a", |
||||
|
File(), |
||||
|
Depends(FooFilePayload), |
||||
|
FooFilePayload, |
||||
|
"FooFilePayload", |
||||
|
), |
||||
|
( |
||||
|
"/file-b", |
||||
|
Depends(BarFilePayload), |
||||
|
File(), |
||||
|
BarFilePayload, |
||||
|
"BarFilePayload", |
||||
|
), |
||||
|
], |
||||
|
) |
||||
|
def test_openapi_file_depends_merge( |
||||
|
self, |
||||
|
path: str, |
||||
|
ann1: Any, |
||||
|
ann2: Any, |
||||
|
model_cls: type[BasePayload], |
||||
|
expected_ref_suffix: str, |
||||
|
) -> None: |
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.post(path) |
||||
|
def route_file( |
||||
|
data: Annotated[BasePayload, ann1, ann2], |
||||
|
) -> None: |
||||
|
assert isinstance(data, model_cls) |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
schema = client.get("/openapi.json").json() |
||||
|
rb = schema["paths"][path]["post"]["requestBody"] |
||||
|
content = rb["content"] |
||||
|
assert "multipart/form-data" in content |
||||
|
ref = content["multipart/form-data"]["schema"]["$ref"] |
||||
|
assert ref.endswith(f"/{expected_ref_suffix}") |
||||
|
|
||||
|
def test_runtime_file_validates_concrete_model(self) -> None: |
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.post("/file-c") |
||||
|
def route_file( |
||||
|
data: Annotated[BasePayload, File(), Depends(FooFilePayload)], |
||||
|
) -> dict[str, str]: |
||||
|
return {"extra": data.extra_foo, "fn": data.blob.filename or ""} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
r = client.post( |
||||
|
"/file-c", |
||||
|
data={"kind": "foo", "extra_foo": "u"}, |
||||
|
files={"blob": ("up.txt", BytesIO(b"xyz"), "text/plain")}, |
||||
|
) |
||||
|
assert r.status_code == status.HTTP_200_OK |
||||
|
assert r.json() == {"extra": "u", "fn": "up.txt"} |
||||
|
|
||||
|
bad = client.post( |
||||
|
"/file-c", |
||||
|
data={"kind": "foo"}, |
||||
|
files={"blob": ("up.txt", BytesIO(b"x"), "text/plain")}, |
||||
|
) |
||||
|
assert bad.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT |
||||
|
|
||||
|
def test_rejects_file_and_form_together(self) -> None: |
||||
|
app = FastAPI() |
||||
|
|
||||
|
with pytest.raises(FastAPIError, match="multiple `Body`"): |
||||
|
|
||||
|
@app.post("/file-conflict") |
||||
|
def route_conflict( |
||||
|
data: Annotated[ |
||||
|
BasePayload, |
||||
|
File(), |
||||
|
Form(), |
||||
|
Depends(FooPayload), |
||||
|
], |
||||
|
) -> None: |
||||
|
pass # pragma: no cover |
||||
@ -0,0 +1,64 @@ |
|||||
|
from typing import Annotated, Any |
||||
|
|
||||
|
import pytest |
||||
|
from fastapi import Depends, FastAPI, Form, status |
||||
|
from fastapi.testclient import TestClient |
||||
|
|
||||
|
from tests._annotated_body_depends_merge_common import ( |
||||
|
BarPayload, |
||||
|
BasePayload, |
||||
|
FooPayload, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
class TestAnnotatedBodyDependsMergeForm: |
||||
|
@pytest.mark.parametrize( |
||||
|
("path", "ann1", "ann2", "model_cls", "expected_ref_suffix"), |
||||
|
[ |
||||
|
("/form-a", Form(), Depends(FooPayload), FooPayload, "FooPayload"), |
||||
|
("/form-b", Depends(BarPayload), Form(), BarPayload, "BarPayload"), |
||||
|
], |
||||
|
) |
||||
|
def test_openapi_form_depends_merge( |
||||
|
self, |
||||
|
path: str, |
||||
|
ann1: Any, |
||||
|
ann2: Any, |
||||
|
model_cls: type[BasePayload], |
||||
|
expected_ref_suffix: str, |
||||
|
) -> None: |
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.post(path) |
||||
|
def route_form( |
||||
|
data: Annotated[BasePayload, ann1, ann2], |
||||
|
) -> None: |
||||
|
assert isinstance(data, model_cls) |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
schema = client.get("/openapi.json").json() |
||||
|
rb = schema["paths"][path]["post"]["requestBody"] |
||||
|
content = rb["content"] |
||||
|
assert "application/x-www-form-urlencoded" in content |
||||
|
ref = content["application/x-www-form-urlencoded"]["schema"]["$ref"] |
||||
|
assert ref.endswith(f"/{expected_ref_suffix}") |
||||
|
|
||||
|
def test_runtime_form_validates_concrete_model(self) -> None: |
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.post("/form-c") |
||||
|
def route_form( |
||||
|
data: Annotated[BasePayload, Form(), Depends(FooPayload)], |
||||
|
) -> dict[str, str]: |
||||
|
return {"extra": data.extra_foo} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
r = client.post( |
||||
|
"/form-c", |
||||
|
data={"kind": "foo", "extra_foo": "z"}, |
||||
|
) |
||||
|
assert r.status_code == status.HTTP_200_OK |
||||
|
assert r.json() == {"extra": "z"} |
||||
|
|
||||
|
bad = client.post("/form-c", data={"kind": "foo"}) |
||||
|
assert bad.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT |
||||
@ -0,0 +1,144 @@ |
|||||
|
"""Query() alongside a second parameter that uses Body/Form/File + Depends(model).""" |
||||
|
|
||||
|
from io import BytesIO |
||||
|
from typing import Annotated, Any |
||||
|
|
||||
|
from fastapi import Body, Depends, FastAPI, File, Form, Query |
||||
|
from fastapi.testclient import TestClient |
||||
|
|
||||
|
from tests._annotated_body_depends_merge_common import ( |
||||
|
BarFilePayload, |
||||
|
BarPayload, |
||||
|
BasePayload, |
||||
|
FooFilePayload, |
||||
|
FooPayload, |
||||
|
openapi_request_body_schema_ref, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
def _param_names(post_schema: dict[str, Any]) -> list[str]: |
||||
|
params = post_schema.get("parameters") or [] |
||||
|
return [p["name"] for p in params] |
||||
|
|
||||
|
|
||||
|
class TestQueryPlusMergedShape: |
||||
|
def test_openapi_query_plus_json_body(self) -> None: |
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.post("/mix-json") |
||||
|
def route( |
||||
|
client_id: Annotated[str, Query()], |
||||
|
data: Annotated[BasePayload, Body(), Depends(FooPayload)], |
||||
|
) -> None: |
||||
|
assert isinstance(data, FooPayload) |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
schema = client.get("/openapi.json").json() |
||||
|
post = schema["paths"]["/mix-json"]["post"] |
||||
|
assert "client_id" in _param_names(post) |
||||
|
ref = openapi_request_body_schema_ref( |
||||
|
schema, |
||||
|
path="/mix-json", |
||||
|
method="post", |
||||
|
content_type="application/json", |
||||
|
) |
||||
|
assert ref.endswith("/FooPayload") |
||||
|
|
||||
|
def test_runtime_query_plus_json_body(self) -> None: |
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.post("/r-json") |
||||
|
def route( |
||||
|
client_id: Annotated[str, Query()], |
||||
|
data: Annotated[BasePayload, Body(), Depends(FooPayload)], |
||||
|
) -> dict[str, str]: |
||||
|
return {"client": client_id, "extra": data.extra_foo} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
r = client.post( |
||||
|
"/r-json?client_id=c1", |
||||
|
json={"kind": "foo", "extra_foo": "e"}, |
||||
|
) |
||||
|
assert r.status_code == 200 |
||||
|
assert r.json() == {"client": "c1", "extra": "e"} |
||||
|
|
||||
|
def test_openapi_query_plus_form(self) -> None: |
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.post("/mix-form") |
||||
|
def route( |
||||
|
client_id: Annotated[str, Query()], |
||||
|
data: Annotated[BasePayload, Form(), Depends(BarPayload)], |
||||
|
) -> None: |
||||
|
assert isinstance(data, BarPayload) |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
schema = client.get("/openapi.json").json() |
||||
|
post = schema["paths"]["/mix-form"]["post"] |
||||
|
assert "client_id" in _param_names(post) |
||||
|
rb = post["requestBody"]["content"] |
||||
|
assert "application/x-www-form-urlencoded" in rb |
||||
|
ref = rb["application/x-www-form-urlencoded"]["schema"]["$ref"] |
||||
|
assert ref.endswith("/BarPayload") |
||||
|
|
||||
|
def test_runtime_query_plus_form(self) -> None: |
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.post("/r-form") |
||||
|
def route( |
||||
|
client_id: Annotated[str, Query()], |
||||
|
data: Annotated[BasePayload, Form(), Depends(FooPayload)], |
||||
|
) -> dict[str, str]: |
||||
|
return {"client": client_id, "extra": data.extra_foo} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
r = client.post( |
||||
|
"/r-form", |
||||
|
params={"client_id": "c2"}, |
||||
|
data={"kind": "foo", "extra_foo": "f"}, |
||||
|
) |
||||
|
assert r.status_code == 200 |
||||
|
assert r.json() == {"client": "c2", "extra": "f"} |
||||
|
|
||||
|
def test_openapi_query_plus_file_multipart(self) -> None: |
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.post("/mix-file") |
||||
|
def route( |
||||
|
client_id: Annotated[str, Query()], |
||||
|
data: Annotated[BasePayload, File(), Depends(FooFilePayload)], |
||||
|
) -> None: |
||||
|
assert isinstance(data, FooFilePayload) |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
schema = client.get("/openapi.json").json() |
||||
|
post = schema["paths"]["/mix-file"]["post"] |
||||
|
assert "client_id" in _param_names(post) |
||||
|
rb = post["requestBody"]["content"] |
||||
|
assert "multipart/form-data" in rb |
||||
|
ref = rb["multipart/form-data"]["schema"]["$ref"] |
||||
|
assert ref.endswith("/FooFilePayload") |
||||
|
|
||||
|
def test_runtime_query_plus_file_multipart(self) -> None: |
||||
|
app = FastAPI() |
||||
|
|
||||
|
@app.post("/r-file") |
||||
|
def route( |
||||
|
client_id: Annotated[str, Query()], |
||||
|
data: Annotated[BasePayload, File(), Depends(BarFilePayload)], |
||||
|
) -> dict[str, str]: |
||||
|
return { |
||||
|
"client": client_id, |
||||
|
"extra": data.extra_bar, |
||||
|
"fn": data.blob.filename or "", |
||||
|
} |
||||
|
|
||||
|
client = TestClient(app) |
||||
|
r = client.post( |
||||
|
"/r-file", |
||||
|
params={"client_id": "c3"}, |
||||
|
data={"kind": "bar", "extra_bar": "b"}, |
||||
|
files={"blob": ("up.bin", BytesIO(b"abc"), "application/octet-stream")}, |
||||
|
) |
||||
|
assert r.status_code == 200 |
||||
|
assert r.json() == {"client": "c3", "extra": "b", "fn": "up.bin"} |
||||
Loading…
Reference in new issue