diff --git a/CHANGELOG.md b/CHANGELOG.md index 973d236..2804f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ Please follow [the Keep a Changelog standard](https://keepachangelog.com/en/1.0. ## [Unreleased] +## [4.4.0] + +### Added + +* Support for [webhooks](https://fastapi.tiangolo.com/advanced/openapi-webhooks/) in swagger +* Automatic generation of versioned routes and webhooks upon the first request to Cadwyn. Notice that if you were using some of cadwyn's internal interfaces, this might break your code. If it did, make an issue and let's discuss your use case + ## [4.3.1] ### Fixed diff --git a/Makefile b/Makefile index ea21739..8437396 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ SHELL := /bin/bash py_warn = PYTHONDEVMODE=1 install: - uv sync --all-extras + uv sync --all-extras --dev lint: pre-commit run --all-files diff --git a/cadwyn/applications.py b/cadwyn/applications.py index 19ae1fb..b4246b5 100644 --- a/cadwyn/applications.py +++ b/cadwyn/applications.py @@ -25,6 +25,7 @@ from starlette.types import Lifespan from typing_extensions import Self +from cadwyn._utils import same_definition_as_in from cadwyn.changelogs import CadwynChangelogResource, _generate_changelog from cadwyn.middleware import HeaderVersioningMiddleware, _get_api_version_dependency from cadwyn.route_generation import generate_versioned_routers @@ -96,8 +97,8 @@ def __init__( **extra: Any, ) -> None: self.versions = versions - # TODO: Remove argument entirely in any major version. self._dependency_overrides_provider = FakeDependencyOverridesProvider({}) + self._cadwyn_initialized = False super().__init__( debug=debug, @@ -156,6 +157,8 @@ def __init__( api_version_header_name=api_version_header_name, api_version_var=self.versions.api_version_var, ) + self._versioned_webhook_routers: dict[date, APIRouter] = {} + self._latest_version_router = APIRouter(dependency_overrides_provider=self._dependency_overrides_provider) self.changelog_url = changelog_url self.include_changelog_url_in_schema = include_changelog_url_in_schema @@ -176,6 +179,26 @@ def __init__( default_response_class=default_response_class, ) + @same_definition_as_in(FastAPI.__call__) + async def __call__(self, scope: Any, receive: Any, send: Any) -> None: + if not self._cadwyn_initialized: + self._cadwyn_initialize() + self.__call__ = super().__call__ + await self.__call__(scope, receive, send) + + def _cadwyn_initialize(self) -> None: + generated_routers = generate_versioned_routers( + self._latest_version_router, + webhooks=self.webhooks, + versions=self.versions, + ) + for version, router in generated_routers.endpoints.items(): + self.add_header_versioned_routers(router, header_value=version.isoformat()) + + for version, router in generated_routers.webhooks.items(): + self._versioned_webhook_routers[version] = router + self._cadwyn_initialized = True + def _add_default_versioned_routers(self) -> None: for version in self.versions: self.router.versioned_routers[version.value] = APIRouter(**self._kwargs_to_router) @@ -240,12 +263,8 @@ async def swagger_ui_redirect(req: Request) -> HTMLResponse: ) def generate_and_include_versioned_routers(self, *routers: APIRouter) -> None: - root_router = APIRouter(dependency_overrides_provider=self._dependency_overrides_provider) for router in routers: - root_router.include_router(router) - router_versions = generate_versioned_routers(root_router, versions=self.versions) - for version, router in router_versions.items(): - self.add_header_versioned_routers(router, header_value=version.isoformat()) + self._latest_version_router.include_router(router) async def openapi_jsons(self, req: Request) -> JSONResponse: raw_version = req.query_params.get("version") or req.headers.get(self.router.api_version_header_name) @@ -276,6 +295,10 @@ async def openapi_jsons(self, req: Request) -> JSONResponse: if root_path and root_path not in server_urls and self.root_path_in_servers: self.servers.insert(0, {"url": root_path}) + webhook_routes = None + if version in self._versioned_webhook_routers: + webhook_routes = self._versioned_webhook_routers[version].routes + return JSONResponse( get_openapi( title=self.title, @@ -287,6 +310,7 @@ async def openapi_jsons(self, req: Request) -> JSONResponse: contact=self.contact, license_info=self.license_info, routes=routes, + webhooks=webhook_routes, tags=self.openapi_tags, servers=self.servers, ) diff --git a/cadwyn/route_generation.py b/cadwyn/route_generation.py index 140b88f..4dead3c 100644 --- a/cadwyn/route_generation.py +++ b/cadwyn/route_generation.py @@ -7,7 +7,6 @@ TYPE_CHECKING, Any, Generic, - TypeVar, cast, ) @@ -20,7 +19,7 @@ from issubclass import issubclass as lenient_issubclass from pydantic import BaseModel from starlette.routing import BaseRoute -from typing_extensions import assert_never +from typing_extensions import TypeVar, assert_never from cadwyn._utils import Sentinel from cadwyn.exceptions import ( @@ -48,7 +47,8 @@ from fastapi.dependencies.models import Dependant _Call = TypeVar("_Call", bound=Callable[..., Any]) -_R = TypeVar("_R", bound=fastapi.routing.APIRouter) +_R = TypeVar("_R", bound=APIRouter) +_WR = TypeVar("_WR", bound=APIRouter, default=APIRouter) # This is a hack we do because we can't guarantee how the user will use the router. _DELETED_ROUTE_TAG = "_CADWYN_DELETED_ROUTE" @@ -59,8 +59,21 @@ class _EndpointInfo: endpoint_methods: frozenset[str] -def generate_versioned_routers(router: _R, versions: VersionBundle) -> dict[VersionDate, _R]: - return _EndpointTransformer(router, versions).transform() +@dataclass(slots=True, frozen=True) +class GeneratedRouters(Generic[_R, _WR]): + endpoints: dict[VersionDate, _R] + webhooks: dict[VersionDate, _WR] + + +def generate_versioned_routers( + router: _R, + versions: VersionBundle, + *, + webhooks: _WR | None = None, +) -> GeneratedRouters[_R, _WR]: + if webhooks is None: + webhooks = cast(_WR, APIRouter()) + return _EndpointTransformer(router, versions, webhooks).transform() class VersionedAPIRouter(fastapi.routing.APIRouter): @@ -77,30 +90,36 @@ def only_exists_in_older_versions(self, endpoint: _Call) -> _Call: return endpoint -class _EndpointTransformer(Generic[_R]): - def __init__(self, parent_router: _R, versions: VersionBundle) -> None: +class _EndpointTransformer(Generic[_R, _WR]): + def __init__(self, parent_router: _R, versions: VersionBundle, webhooks: _WR) -> None: super().__init__() self.parent_router = parent_router self.versions = versions + self.parent_webhooks_router = webhooks self.schema_generators = generate_versioned_models(versions) self.routes_that_never_existed = [ route for route in parent_router.routes if isinstance(route, APIRoute) and _DELETED_ROUTE_TAG in route.tags ] - def transform(self) -> dict[VersionDate, _R]: + def transform(self) -> GeneratedRouters[_R, _WR]: router = deepcopy(self.parent_router) + webhook_router = deepcopy(self.parent_webhooks_router) routers: dict[VersionDate, _R] = {} + webhook_routers: dict[VersionDate, _WR] = {} for version in self.versions: self.schema_generators[str(version.value)].annotation_transformer.migrate_router_to_version(router) + self.schema_generators[str(version.value)].annotation_transformer.migrate_router_to_version(webhook_router) self._validate_all_data_converters_are_applied(router, version) routers[version.value] = router + webhook_routers[version.value] = webhook_router # Applying changes for the next version router = deepcopy(router) - self._apply_endpoint_changes_to_router(router, version) + webhook_router = deepcopy(webhook_router) + self._apply_endpoint_changes_to_router(router.routes + webhook_router.routes, version) if self.routes_that_never_existed: raise RouterGenerationError( @@ -146,7 +165,13 @@ def transform(self) -> dict[VersionDate, _R]: for route in router.routes if not (isinstance(route, fastapi.routing.APIRoute) and _DELETED_ROUTE_TAG in route.tags) ] - return routers + for _, webhook_router in webhook_routers.items(): + webhook_router.routes = [ + route + for route in webhook_router.routes + if not (isinstance(route, fastapi.routing.APIRoute) and _DELETED_ROUTE_TAG in route.tags) + ] + return GeneratedRouters(routers, webhook_routers) def _validate_all_data_converters_are_applied(self, router: APIRouter, version: Version): path_to_route_methods_mapping, head_response_models, head_request_bodies = self._extract_all_routes_identifiers( @@ -223,10 +248,9 @@ def _extract_all_routes_identifiers( # TODO (https://github.com/zmievsa/cadwyn/issues/28): Simplify def _apply_endpoint_changes_to_router( # noqa: C901 self, - router: fastapi.routing.APIRouter, + routes: list[BaseRoute] | list[APIRoute], version: Version, ): - routes = router.routes for version_change in version.changes: for instruction in version_change.alter_endpoint_instructions: original_routes = _get_routes( diff --git a/pyproject.toml b/pyproject.toml index 4cc2b69..da693bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cadwyn" -version = "4.3.1" +version = "4.4.0" description = "Production-ready community-driven modern Stripe-like API versioning in FastAPI" authors = [{ name = "Stanislav Zmiev", email = "zmievsa@gmail.com" }] license = "MIT" diff --git a/tests/_resources/versioned_app/app.py b/tests/_resources/versioned_app/app.py index 74ae356..0f426f6 100755 --- a/tests/_resources/versioned_app/app.py +++ b/tests/_resources/versioned_app/app.py @@ -8,10 +8,9 @@ from cadwyn import Cadwyn from cadwyn.structure.versions import Version, VersionBundle -from tests._resources.utils import BASIC_HEADERS from tests._resources.versioned_app.v2021_01_01 import router as v2021_01_01_router from tests._resources.versioned_app.v2022_01_02 import router as v2022_01_02_router -from tests._resources.versioned_app.webhooks import router as webhooks_router +from tests._resources.versioned_app.webhooks import router as unversioned_router # TODO: Add better tests for covering lifespan @@ -23,17 +22,16 @@ async def lifespan(app: FastAPI): versioned_app = Cadwyn(versions=VersionBundle(Version(date(2021, 1, 1))), lifespan=lifespan) versioned_app.add_header_versioned_routers(v2021_01_01_router, header_value="2021-01-01") versioned_app.add_header_versioned_routers(v2022_01_02_router, header_value="2022-02-02") -versioned_app.include_router(webhooks_router) +versioned_app.include_router(unversioned_router) versioned_app_with_custom_api_version_var = Cadwyn( versions=VersionBundle(Version(date(2021, 1, 1))), lifespan=lifespan, api_version_var=ContextVar("My api version") ) versioned_app_with_custom_api_version_var.add_header_versioned_routers(v2021_01_01_router, header_value="2021-01-01") versioned_app_with_custom_api_version_var.add_header_versioned_routers(v2022_01_02_router, header_value="2022-02-02") -versioned_app_with_custom_api_version_var.include_router(webhooks_router) +versioned_app_with_custom_api_version_var.include_router(unversioned_router) # TODO: We should not have any clients that are run like this. Instead, all of them must run using "with" -client = TestClient(versioned_app, raise_server_exceptions=False, headers=BASIC_HEADERS) client_without_headers = TestClient(versioned_app) client_without_headers_and_with_custom_api_version_var = TestClient(versioned_app_with_custom_api_version_var) diff --git a/tests/_resources/versioned_app/webhooks.py b/tests/_resources/versioned_app/webhooks.py index 17873c0..6ec4284 100755 --- a/tests/_resources/versioned_app/webhooks.py +++ b/tests/_resources/versioned_app/webhooks.py @@ -3,6 +3,6 @@ router = APIRouter(prefix="/v1") -@router.post("/webhooks", response_model=dict) +@router.post("/unversioned", response_model=dict) def read_root(): return {"saved": True} diff --git a/tests/conftest.py b/tests/conftest.py index b7522b4..4b78516 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -105,6 +105,7 @@ def __call__( ) ) app.generate_and_include_versioned_routers(router) + app._cadwyn_initialize() return app diff --git a/tests/test_applications.py b/tests/test_applications.py index df52f58..474bb30 100644 --- a/tests/test_applications.py +++ b/tests/test_applications.py @@ -6,10 +6,13 @@ from fastapi import APIRouter, BackgroundTasks, Depends, FastAPI from fastapi.routing import APIRoute from fastapi.testclient import TestClient +from pydantic import BaseModel from cadwyn import Cadwyn from cadwyn.route_generation import VersionedAPIRouter -from cadwyn.structure.versions import HeadVersion, Version, VersionBundle +from cadwyn.structure.endpoints import endpoint +from cadwyn.structure.schemas import schema +from cadwyn.structure.versions import HeadVersion, Version, VersionBundle, VersionChange from tests._resources.utils import BASIC_HEADERS, DEFAULT_API_VERSION from tests._resources.versioned_app.app import ( client_without_headers, @@ -162,8 +165,8 @@ def test__header_based_versioning__invalid_version_header_format__should_raise_4 assert resp.json()[0]["loc"] == ["header", "x-api-version"] -def test__get_webhooks_router(): - resp = client_without_headers.post("/v1/webhooks") +def test__get_unversioned_router(): + resp = client_without_headers.post("/v1/unversioned") assert resp.status_code == 200 assert resp.json() == {"saved": True} @@ -254,14 +257,14 @@ def test__get_docs__specific_version(): assert resp.status_code == 200 -def test__get_webhooks_with_redirect(): - resp = client_without_headers.post("/v1/webhooks/") +def test__get_unversioned_with_redirect(): + resp = client_without_headers.post("/v1/unversioned/") assert resp.status_code == 200 assert resp.json() == {"saved": True} -def test__get_webhooks_as_partial_because_of_method(): - resp = client_without_headers.patch("/v1/webhooks") +def test__get_unversioned_as_partial_because_of_method(): + resp = client_without_headers.patch("/v1/unversioned") assert resp.status_code == 405 @@ -291,3 +294,60 @@ async def send_notification(email: str, background_tasks: BackgroundTasks): resp = client.post("/send-notification/test@example.com", headers=BASIC_HEADERS) assert resp.status_code == 200, resp.json() assert background_task_data == ("test@example.com", "some notification") + + +def test__webhooks(): + webhooks = VersionedAPIRouter() + + class Subscription(BaseModel): + username: str + monthly_fee: float + start_date: str + + @webhooks.post("new-subscription") + def new_subscription(body: Subscription): # pragma: no cover + """ + When a new user subscribes to your service we'll send you a POST request with this + data to the URL that you register for the event `new-subscription` in the dashboard. + """ + + class MyVersionChange(VersionChange): + description = "Mess with webhooks" + instructions_to_migrate_to_previous_version = [ + endpoint("new-subscription", ["POST"]).didnt_exist, + schema(Subscription).field("monthly_fee").didnt_exist, + ] + + app = Cadwyn( + versions=VersionBundle(HeadVersion(), Version("2023-04-12", MyVersionChange), Version("2022-11-16")), + webhooks=webhooks, + ) + + @app.webhooks.post("post-subscription") # pragma: no cover + def post_subscription(body: Subscription): # pragma: no cover + """This should also be there""" + + with TestClient(app) as client: + resp = client.get("/openapi.json?version=2023-04-12") + openapi_dict = resp.json() + + assert "webhooks" in openapi_dict, "'webhooks' section is missing" + assert "new-subscription" in openapi_dict["webhooks"], "'new-subscription' webhook is missing" + assert "post-subscription" in openapi_dict["webhooks"], "'post-subscription' webhook is missing" + assert "post" in openapi_dict["webhooks"]["post-subscription"], "POST method for 'post-subscription' is missing" + assert "Subscription" in openapi_dict["components"]["schemas"], "'Subscription' component is missing" + assert ( + "monthly_fee" in openapi_dict["components"]["schemas"]["Subscription"]["properties"] + ), "monthly_fee field is missing" + + resp = client.get("/openapi.json?version=2022-11-16") + openapi_dict = resp.json() + + assert "webhooks" in openapi_dict, "'webhooks' section is missing" + assert "new-subscription" not in openapi_dict["webhooks"], "'new-subscription' webhook is missing" + assert "post-subscription" in openapi_dict["webhooks"], "'post-subscription' webhook is present" + assert "post" in openapi_dict["webhooks"]["post-subscription"], "POST method for 'post-subscription' is missing" + assert "Subscription" in openapi_dict["components"]["schemas"], "'Subscription' component is missing" + assert ( + "monthly_fee" not in openapi_dict["components"]["schemas"]["Subscription"]["properties"] + ), "monthly_fee field is present yet it must be deleted" diff --git a/tests/test_router_generation.py b/tests/test_router_generation.py index 3a3c6c2..53ddac6 100644 --- a/tests/test_router_generation.py +++ b/tests/test_router_generation.py @@ -190,10 +190,10 @@ class MyVersionChange1(VersionChange): ) routers = generate_versioned_routers(router, versions=versions) - assert len(routers[date(2003, 1, 1)].routes) == 0 - assert len(routers[date(2002, 1, 1)].routes) == 1 - assert len(routers[date(2001, 1, 1)].routes) == 0 - assert len(routers[date(2000, 1, 1)].routes) == 1 + assert len(routers.endpoints[date(2003, 1, 1)].routes) == 0 + assert len(routers.endpoints[date(2002, 1, 1)].routes) == 1 + assert len(routers.endpoints[date(2001, 1, 1)].routes) == 0 + assert len(routers.endpoints[date(2000, 1, 1)].routes) == 1 @pytest.mark.parametrize( @@ -599,14 +599,14 @@ class MyVersionChange1(VersionChange): ) routers = generate_versioned_routers(router, versions=versions) - assert len(routers[date(2002, 1, 1)].routes) == 0 - assert len(routers[date(2001, 1, 1)].routes) == 1 - assert len(routers[date(2000, 1, 1)].routes) == 2 + assert len(routers.endpoints[date(2002, 1, 1)].routes) == 0 + assert len(routers.endpoints[date(2001, 1, 1)].routes) == 1 + assert len(routers.endpoints[date(2000, 1, 1)].routes) == 2 - assert endpoints_equal(routers[date(2001, 1, 1)].routes[0].endpoint, route_to_restore_first) # pyright: ignore + assert endpoints_equal(routers.endpoints[date(2001, 1, 1)].routes[0].endpoint, route_to_restore_first) # pyright: ignore assert { - get_wrapped_endpoint(routers[date(2000, 1, 1)].routes[0].endpoint), # pyright: ignore - get_wrapped_endpoint(routers[date(2000, 1, 1)].routes[1].endpoint), # pyright: ignore + get_wrapped_endpoint(routers.endpoints[date(2000, 1, 1)].routes[0].endpoint), # pyright: ignore + get_wrapped_endpoint(routers.endpoints[date(2000, 1, 1)].routes[1].endpoint), # pyright: ignore } == { route_to_restore_first, route_to_restore_second, @@ -1014,9 +1014,9 @@ class V2002(VersionChange): ) routers = generate_versioned_routers(router, versions=versions) - assert client(routers[date(2002, 1, 1)]).get("/test").json() == {"detail": "Not Found"} - assert client(routers[date(2001, 1, 1)]).get("/test").json() == 83 - assert client(routers[date(2000, 1, 1)]).get("/test").json() == 83 + assert client(routers.endpoints[date(2002, 1, 1)]).get("/test").json() == {"detail": "Not Found"} + assert client(routers.endpoints[date(2001, 1, 1)]).get("/test").json() == 83 + assert client(routers.endpoints[date(2000, 1, 1)]).get("/test").json() == 83 def test__cascading_router_didnt_exist( @@ -1041,13 +1041,13 @@ class V2002(VersionChange): ) routers = generate_versioned_routers(router, versions=versions) - assert client(routers[date(2002, 1, 1)]).get("/test").json() == 83 + assert client(routers.endpoints[date(2002, 1, 1)]).get("/test").json() == 83 - assert client(routers[date(2001, 1, 1)]).get("/test").json() == { + assert client(routers.endpoints[date(2001, 1, 1)]).get("/test").json() == { "detail": "Not Found", } - assert client(routers[date(2000, 1, 1)]).get("/test").json() == { + assert client(routers.endpoints[date(2000, 1, 1)]).get("/test").json() == { "detail": "Not Found", } @@ -1081,7 +1081,7 @@ class V2001(VersionChange): root_router.include_router(router) root_router.include_router(router2) - routers = generate_versioned_routers(root_router, versions=versions) + routers = generate_versioned_routers(root_router, versions=versions).endpoints assert all(type(r) is APIRouter for r in routers.values()) assert len(routers[date(2001, 1, 1)].routes) == 2 assert len(routers[date(2000, 1, 1)].routes) == 1