Skip to content

Commit

Permalink
Add openapi webhooks support (#223)
Browse files Browse the repository at this point in the history
  • Loading branch information
zmievsa authored Oct 6, 2024
1 parent a3aeb9f commit 0dda043
Show file tree
Hide file tree
Showing 10 changed files with 164 additions and 50 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 30 additions & 6 deletions cadwyn/applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down
48 changes: 36 additions & 12 deletions cadwyn/route_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
TYPE_CHECKING,
Any,
Generic,
TypeVar,
cast,
)

Expand All @@ -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 (
Expand Down Expand Up @@ -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"

Expand All @@ -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):
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = "[email protected]" }]
license = "MIT"
Expand Down
8 changes: 3 additions & 5 deletions tests/_resources/versioned_app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion tests/_resources/versioned_app/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def __call__(
)
)
app.generate_and_include_versioned_routers(router)
app._cadwyn_initialize()
return app


Expand Down
74 changes: 67 additions & 7 deletions tests/test_applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}

Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -291,3 +294,60 @@ async def send_notification(email: str, background_tasks: BackgroundTasks):
resp = client.post("/send-notification/[email protected]", headers=BASIC_HEADERS)
assert resp.status_code == 200, resp.json()
assert background_task_data == ("[email protected]", "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"
Loading

0 comments on commit 0dda043

Please sign in to comment.