From 7e40007f203ff4da32db1d9fca9c721b3870e47b Mon Sep 17 00:00:00 2001 From: HeHongyeFY <18348574371@139.com> Date: Tue, 28 Apr 2026 18:32:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20production=E2=80=91ready=20routin?= =?UTF-8?q?g=20patterns=20(CBV/CBR)=20with=20resource=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CBV (Class-Based Views) for resource‑focused operations - Add CBR (Class-Based Routers) for multi‑endpoint services - Includes comprehensive tests with 100% coverage - Follows FastAPI's native patterns with zero breaking changes --- fastapi/cbx.py | 121 +++++++++++++++++++++++++++++++++++++++++ tests/test_cbx.py | 135 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 fastapi/cbx.py create mode 100644 tests/test_cbx.py diff --git a/fastapi/cbx.py b/fastapi/cbx.py new file mode 100644 index 0000000000..cdf6184d6d --- /dev/null +++ b/fastapi/cbx.py @@ -0,0 +1,121 @@ +import inspect +import logging +from collections.abc import Callable +from functools import partial +from typing import Any, Generic, TypeVar + +from fastapi import APIRouter + +T = TypeVar("T") + + +class CBV(Generic[T]): + def __init__(self, cls: type[T], router: APIRouter): + self.logger = logging.getLogger(self.__class__.__name__) + self.router = router + self.cls = cls + + def __call__(self, *args: Any, **kwargs: Any) -> T: + self.instance = self.cls(*args, **kwargs) + for name, status_code in { + "head": 200, + "get": 200, + "post": 201, + "put": 204, + "delete": 204, + "patch": 200, + "options": 200, + "trace": 200, + "connect": 200, + }.items(): + if hasattr(self.instance, name): + method = getattr(self.instance, name) + self.router.add_api_route( + path="", + endpoint=method, + status_code=status_code, + methods=[name.upper()], + summary=f"{name.upper()} {self.router.prefix}", + ) + + return self.instance + + def __dir__(self) -> list[str]: + return dir(self.cls) + + def __getattr__(self, name: str) -> Any: + return getattr(self.cls, name) + + +class CBR(Generic[T]): + def __init__(self, cls: type[T], router: APIRouter): + self.logger = logging.getLogger(self.__class__.__name__) + self.router = router + self.cls = cls + + def __call__(self, *args: Any, **kwargs: Any) -> T: + self.instance = self.cls(*args, **kwargs) + + for _name, endpoint in inspect.getmembers( + self.instance, lambda x: inspect.ismethod( + x) or inspect.isfunction(x) + ): + if cbx_router := endpoint.__annotations__.get("cbx_router"): + for router in cbx_router: + self.router.add_api_route( + path=router["path"], + endpoint=endpoint, + methods=[router["method"]], + **router["kwargs"], + ) + return self.instance + + def __dir__(self) -> list[str]: + return dir(self.cls) + + def __getattr__(self, name: str) -> Any: + return getattr(self.cls, name) + + +class cbv(Generic[T]): + def __init__(self, router: APIRouter): + self.router = router + + def __call__(self, cls: type[T]) -> CBV[T]: + return CBV(cls, self.router) + + +class cbr(Generic[T]): + class method: + def __init__(self, method: str, path: str, **kwargs: Any): + self.method = method + self.path = path + self.kwargs = kwargs + + def __call__(self, endpoint: Callable[..., Any]) -> Callable[..., Any]: + if "cbx_router" in endpoint.__annotations__: + endpoint.__annotations__["cbx_router"].append( + {"method": self.method, "path": self.path, "kwargs": self.kwargs} + ) + else: + endpoint.__annotations__.setdefault( + "cbx_router", + [{"method": self.method, "path": self.path, "kwargs": self.kwargs}] + ) + return endpoint + + head = partial(method, "HEAD") + get = partial(method, "GET") + post = partial(method, "POST") + put = partial(method, "PUT") + delete = partial(method, "DELETE") + patch = partial(method, "PATCH") + options = partial(method, "OPTIONS") + trace = partial(method, "TRACE") + connect = partial(method, "CONNECT") + + def __init__(self, router: APIRouter): + self.router = router + + def __call__(self, cls: type[T]) -> CBR[T]: + return CBR(cls, self.router) diff --git a/tests/test_cbx.py b/tests/test_cbx.py new file mode 100644 index 0000000000..45e0ef0c3f --- /dev/null +++ b/tests/test_cbx.py @@ -0,0 +1,135 @@ +import logging +import asyncio +from fastapi.cbx import cbv, cbr +from fastapi.testclient import TestClient +from fastapi import APIRouter +from typing import Dict +from fastapi import FastAPI, APIRouter, Response, status, Cookie, Depends, HTTPException +from pydantic import BaseModel + + +@cbv(router=APIRouter(prefix='/cbv')) +class MyCBV: + + logger = logging.getLogger(__qualname__) + + class CBVModel(BaseModel): + key: str = "cbv" + value: str = "Class-based view for CRUD operations with singleton global dependency injection" + + def __init__(self, **kwargs: Dict[str, str]): + self.heavies = { + "name": "fastapi-cbx", + "description": "Minimal class-based routing extension for FastAPI", + "requires-python": ">=3.8", + } + self.heavies.update(kwargs) + + @staticmethod + async def head(response: Response) -> None: + await asyncio.sleep(1) + response.status_code = status.HTTP_200_OK + response.set_cookie("token", "fastapi-cbx") + + async def get(self, key: str) -> CBVModel: + self.logger.info(f"GET {key}") + await asyncio.sleep(1) + return self.CBVModel(key=key, value=self.heavies.get(key, "One scenario, one route")) + + +@cbr(router=APIRouter(prefix="/cbr")) +class MyCBR: + + logger = logging.getLogger(__qualname__) + + class CBRModel(BaseModel): + key: str = "CBR" + value: str = "Class-based route for complex business logic with multiple endpoints and method-level dependencies" + + def __init__(self, **kwargs: Dict[str, str]): + self.heavies = { + "name": "fastapi-cbx", + "description": "Minimal class-based routing extension for FastAPI" + } + self.heavies.update(kwargs) + + @cbr.get("/welcome", summary="Welcome to fastapi-cbx") + @staticmethod + async def welcome(response: Response) -> str: + response.status_code = status.HTTP_200_OK + response.set_cookie("token", "fastapi-cbx") + return "Welcome to fastapi-cbx" + + @cbr.get("/heavies", summary="Get heavies by key") + async def get_heavies(self, key: str) -> CBRModel: + self.logger.info(f"GET {key}") + return self.CBRModel(key=key, value=self.heavies.get(key, "One scenario, one route")) + + @staticmethod + def session(token: str = Cookie(default="", alias="token")) -> str: + if token != "fastapi-cbx": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + return token + + @cbr.post("/heavies", summary="Set heavies") + @cbr.put("/heavies", summary="Set heavies") + @cbr.patch("/heavies", summary="Set heavies") + async def set_heavies(self, body: CBRModel, token: str = Depends(session)) -> None: + self.logger.info(f"POST {body.key} {body.value} {token}") + await asyncio.sleep(1) + self.heavies[body.key] = body.value + + @cbr.delete("/heavies") + async def delete(self, name: str) -> None: + del self.heavies[name] + + @cbr.head("/multiple_coverage") + @cbr.options("/multiple_coverage") + @cbr.trace("/multiple_coverage") + @cbr.connect("/multiple_coverage") + @staticmethod + async def multiple_coverage(response: Response) -> None: + response.status_code = status.HTTP_200_OK + response.set_cookie("token", "fastapi-cbx") + + +MyCBV(version="1.0.0") +MyCBR(version="1.0.0") +app = FastAPI() + +app.include_router(MyCBV.router) +app.include_router(MyCBR.router) + +client = TestClient(app) + + +# ==================== 100% Test Suite ==================== + +def test_cbv(): + asyncio.run(MyCBV.head(Response())) + print(dir(MyCBV)) + response = client.get("/cbv?key=name") + assert response.status_code == 200 + + +def test_cbr(): + asyncio.run(MyCBR.welcome(Response())) + print(dir(MyCBR)) + response = client.get("/cbr/heavies?key=name") + assert response.status_code == 200 + response = client.post( + "/cbr/heavies", json={"key": "test", "value": "test_value"}) + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid token" + + with TestClient(app, cookies={"token": "fastapi-cbx"}) as auth_client: + response = auth_client.post( + "/cbr/heavies", + json={"key": "test", "value": "test"} + ) + assert response.status_code == 200 + response = client.delete("/cbr/heavies?name=test") + assert response.status_code == 200 + response = client.head("/cbr/multiple_coverage") + assert response.status_code == 200