diff --git a/README.md b/README.md index 5dfb21d3dad99d19d4fa4d1988364d0b7ee0a24a..111b7b4dff68001e10ae80a1d36b40069f379bb2 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,8 @@ $ uvicorn src.app.main:app --reload --reload-dir=./src/app 3. In the `handler.py` file, you should create a new function that will handle the event. The function should be take the following arguments: - body (dict): The body of the request 4. In the `schema.py` file, you should create 2 new Pydantic models that will be used to validate the body of the request and the response of the handler function. + - The first model should be used to validate the body of the request. + - The second model should be used to validate the response of the handler function. 5. In the `__init__.py` file, you must have the following code: ```python @@ -147,22 +149,4 @@ handler_function = your_handler_function response_model = YourResponseSchema ``` -6. In the `src/app/api/v1/endpoints/webhook.py` file, you should import the component and add it to the route : - -```Python -from app.components import ( - your_component, -) - -ResponseModel = Union[ - ..., - your_component.response_model, -] - -@webhook_handler( - event_name=your_component.event_name, - handler=your_component.handler_function, -) -async def webhook_route(webhook_event: BaseWebhookEvent): - ... -``` +6. All the components arle loaded dynamically in the `src/app/api/v1/endpoints/webhook.py` file. diff --git a/poetry.lock b/poetry.lock index 1727297e21a7d375278de83ec007ea2a62c899aa..9b8e6de0ad6a039951d0e46002a5a5473d9f4e0a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1009,13 +1009,13 @@ all = ["phonenumbers (>=8,<9)", "pycountry (>=22,<23)"] [[package]] name = "pydantic-settings" -version = "2.0.1" +version = "2.0.2" description = "Settings management using Pydantic" optional = false python-versions = ">=3.7" files = [ - {file = "pydantic_settings-2.0.1-py3-none-any.whl", hash = "sha256:579bbcbec3501e62bab73867b097ae10218201950e897463c98a182ffe7ed104"}, - {file = "pydantic_settings-2.0.1.tar.gz", hash = "sha256:f440ec7cfb6dc63f03226c47b0e7803750d1b66a49ed944ac23eb4f0c84f8722"}, + {file = "pydantic_settings-2.0.2-py3-none-any.whl", hash = "sha256:6183a2abeab465d5a3ab69758e9a22d38b0cc2ba193f0b85f6971a252ea630f6"}, + {file = "pydantic_settings-2.0.2.tar.gz", hash = "sha256:342337fff50b23585e807a86dec85037900972364435c55c2fc00d16ff080539"}, ] [package.dependencies] @@ -1077,12 +1077,12 @@ pylint = ">=1.7" [[package]] name = "pylint-pydantic" -version = "0.2.0" +version = "0.2.2" description = "A Pylint plugin to help Pylint understand the Pydantic" optional = false python-versions = ">=3.7" files = [ - {file = "pylint_pydantic-0.2.0-py3-none-any.whl", hash = "sha256:f44e38239c0da3cd1f500f7382ce57aaceb23200c2a675367d3e34b458b597a3"}, + {file = "pylint_pydantic-0.2.2-py3-none-any.whl", hash = "sha256:37f9b16b68f258dcb638efd3a78632e237c80d6cde036fe73514de9cdf11aff2"}, ] [package.dependencies] @@ -1689,4 +1689,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "dd6abd3eff19e9e54a564ceea8147ab86abcf378fb56d5138aeaf0e68349a633" +content-hash = "cfdd00d4f2c9c54ea7d9afafab03f7f62b143b14257e6837604dec2afbbbf95e" diff --git a/pyproject.toml b/pyproject.toml index 7acc1de3a5f37306e7c3b6b4f4bdcd9d0efe6bc9..cdfdb14f996e2b10b3d671287cb6c37e82546568 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "zoom-python-webhook" -version = "0.1.0" +version = "0.1.1" description = "Fast API app for Zoom Webhook" authors = ["Samuel Guillemet <samuel.guillemet@telecom-sudparis.eu>"] readme = "README.md" @@ -9,7 +9,7 @@ packages = [{include = "app", from = "src"}] [tool.poetry.dependencies] python = "^3.10" fastapi = {extras = ["all"], version = "^0.100.0"} -pydantic-settings = "^2.0.1" +pydantic-settings = "^2.0.2" pytz = "^2023.3" @@ -23,7 +23,7 @@ mypy-extensions = "^1.0.0" pylint = "^2.17.4" pytest = "^7.4.0" pytest-cov = "^4.1.0" -pylint-pydantic = "^0.2.0" +pylint-pydantic = "^0.2.2" pytest-env = "^0.8.2" pytest-asyncio = "^0.21.1" httpx = "^0.24.1" diff --git a/src/app/api/v1/endpoints/webhook.py b/src/app/api/v1/endpoints/webhook.py index 4429ec16b3679da26491897b27b0b78ded6aade7..e9369b0f5dd4d3c65b4e0960c4eb0eb4a3b2e207 100644 --- a/src/app/api/v1/endpoints/webhook.py +++ b/src/app/api/v1/endpoints/webhook.py @@ -1,18 +1,18 @@ """ Webhook endpoint. """ import logging -from typing import Union +from typing import Callable, Union from fastapi import APIRouter, Depends, HTTPException -from app.components import ( - endpoint_url_validation, - zoom_room_checked_in, - zoom_room_checked_out, +from app import components +from app.core.base_webhook_event_schema import ( + BaseResponseWebhookEvent, + BaseWebhookEvent, + ErrorModel, ) -from app.core.base_webhook_event_schema import BaseWebhookEvent, ErrorModel -from app.core.decorators import webhook_handler from app.core.dependencies import verify_webhook_signature +from app.utils.load_submodules import load_submodules logger = logging.getLogger("app.api.v1.webhook") @@ -24,32 +24,28 @@ responses = { 501: {"description": "Webhook event not supported.", "model": ErrorModel}, } -# Union of all response models for the webhook handlers. -ResponseModel = Union[ - endpoint_url_validation.response_model, - zoom_room_checked_in.response_model, - zoom_room_checked_out.response_model, +# Load all webhook components and create a list of tuples containing +# the event name, handler function, and response model. +components_tuple: list[ + tuple[str, Callable[..., BaseResponseWebhookEvent], BaseResponseWebhookEvent] +] = [ + ( + getattr(component, "event_name"), + getattr(component, "handler_function"), + getattr(component, "response_model"), + ) + for component in load_submodules(components) ] @router.post( "", # Will be /webhook dependencies=[Depends(verify_webhook_signature)], - response_model=ResponseModel, + response_model=Union[ + tuple(response_model for _, _, response_model in components_tuple) # type: ignore + ], responses={**responses}, ) -@webhook_handler( - event_name=endpoint_url_validation.event_name, - handler_function=endpoint_url_validation.handler_function, -) -@webhook_handler( - event_name=zoom_room_checked_in.event_name, - handler_function=zoom_room_checked_in.handler_function, -) -@webhook_handler( - event_name=zoom_room_checked_out.event_name, - handler_function=zoom_room_checked_out.handler_function, -) async def webhook_route(webhook_event: BaseWebhookEvent): """ Handle incoming webhook events. @@ -60,6 +56,10 @@ async def webhook_route(webhook_event: BaseWebhookEvent): Raises: HTTPException: If the webhook event is not supported. """ + for event_name, handler_function, _ in components_tuple: + if webhook_event.event == event_name: + return handler_function(webhook_event.model_dump()) + logger.warning("Unhandled webhook event: %s", webhook_event.event) raise HTTPException( detail=f"Webhook event {webhook_event.event} not supported.", diff --git a/src/app/core/decorators.py b/src/app/core/decorators.py deleted file mode 100644 index fce073c1e33b99745940683792a6c39d314de10d..0000000000000000000000000000000000000000 --- a/src/app/core/decorators.py +++ /dev/null @@ -1,34 +0,0 @@ -""" This module contains decorators for the webhook handlers. """ - -from typing import Awaitable, Callable - -from app.core.base_webhook_event_schema import ( - BaseResponseWebhookEvent, - BaseWebhookEvent, -) - - -def webhook_handler( - event_name: str, handler_function: Callable[[dict], BaseResponseWebhookEvent] -) -> Callable: - """This function is a decorator that registers a webhook handler. - - Args: - event_name (str): The name of the webhook event. - handler_function (Callable): The handler function for the webhook event. - - Returns: - Callable: The decorator will call either the handler function for the webhook event, - or the base function if the event name does not match. - """ - - def decorator(func: Callable[..., Awaitable]) -> Callable: - async def wrapper(webhook_event: BaseWebhookEvent): - if webhook_event.event != event_name: - return await func(webhook_event) - - return handler_function(webhook_event.model_dump()) - - return wrapper - - return decorator diff --git a/src/app/utils/load_submodules.py b/src/app/utils/load_submodules.py new file mode 100644 index 0000000000000000000000000000000000000000..6570b9078db2c8edf64eae5749140cf8a43fe032 --- /dev/null +++ b/src/app/utils/load_submodules.py @@ -0,0 +1,13 @@ +import importlib +import pkgutil +from types import ModuleType + + +def load_submodules(parent_module: ModuleType) -> list[ModuleType]: + """Load all submodules of a given module.""" + submodules: list[ModuleType] = [] + for _, submodule_name, _ in pkgutil.iter_modules(parent_module.__path__): + submodule_path = f"{parent_module.__name__}.{submodule_name}" + submodule = importlib.import_module(submodule_path) + submodules.append(submodule) + return submodules diff --git a/tests/app/core/test_decorators.py b/tests/app/core/test_decorators.py deleted file mode 100644 index 3684d43d5bee9055434c11e8dcdfd3e71b95f280..0000000000000000000000000000000000000000 --- a/tests/app/core/test_decorators.py +++ /dev/null @@ -1,93 +0,0 @@ -# pylint: disable=unused-argument -from unittest.mock import Mock - -import pytest - -from app.core.base_webhook_event_schema import BaseWebhookEvent -from app.core.decorators import webhook_handler - -webhook_event_test = BaseWebhookEvent( - payload={"plainToken": "q9ibPhGeRZ6ayx5WTrXjRw"}, - event_ts=1689061099652, - event="event.test.1", -) - - -@pytest.mark.asyncio -async def test_webhook_handler(): - mock_handler = Mock() - - @webhook_handler( - event_name="event.test.1", - handler_function=mock_handler, - ) - async def test_func(webhook_event: BaseWebhookEvent): - assert False # This should not be called. - - await test_func(webhook_event_test) - - assert mock_handler.called - assert mock_handler.call_args[0][0]["payload"] == webhook_event_test.payload - assert mock_handler.call_args[0][0]["event_ts"] == webhook_event_test.event_ts - assert mock_handler.call_args[0][0]["event"] == webhook_event_test.event - - -@pytest.mark.asyncio -async def test_webhook_handler_not_event(): - mock_handler = Mock() - - @webhook_handler( - event_name="wrong", - handler_function=mock_handler, - ) - async def test_func(webhook_event: BaseWebhookEvent) -> BaseWebhookEvent: - return webhook_event - - result = await test_func(webhook_event_test) - - assert result == webhook_event_test - assert not mock_handler.called - - -@pytest.mark.asyncio -async def test_webhook_handler_multiple_events_0(): - mock_handler_1 = Mock() - mock_handler_2 = Mock() - - @webhook_handler( - event_name="event.test.1", - handler_function=mock_handler_1, - ) - @webhook_handler( - event_name="event.test.2", - handler_function=mock_handler_2, - ) - async def test_func(webhook_event: BaseWebhookEvent): - assert False - - await test_func(webhook_event_test) - - assert mock_handler_1.called - assert not mock_handler_2.called - - -@pytest.mark.asyncio -async def test_webhook_handler_multiple_events_1(): - mock_handler_1 = Mock() - mock_handler_2 = Mock() - - @webhook_handler( - event_name="event.test.2", - handler_function=mock_handler_2, - ) - @webhook_handler( - event_name="event.test.1", - handler_function=mock_handler_1, - ) - async def test_func(webhook_event: BaseWebhookEvent): - assert False - - await test_func(webhook_event_test) - - assert mock_handler_1.called - assert not mock_handler_2.called diff --git a/tests/app/utils/__init__.py b/tests/app/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/app/utils/test_load_submodules.py b/tests/app/utils/test_load_submodules.py new file mode 100644 index 0000000000000000000000000000000000000000..3ca6045ce799f5413cb24844b197d0a53b07ee44 --- /dev/null +++ b/tests/app/utils/test_load_submodules.py @@ -0,0 +1,33 @@ +from types import ModuleType +from unittest.mock import MagicMock, patch + +from app.utils.load_submodules import load_submodules + + +def test_load_submodules(): + parent_module = MagicMock(spec=ModuleType) + parent_module.__name__ = "parent_module" + parent_module.__path__ = ["path/to/parent_module"] + + mock_submodule1 = MagicMock(spec=ModuleType) + mock_submodule2 = MagicMock(spec=ModuleType) + + mock_iter_modules = MagicMock() + mock_iter_modules.return_value = [ + ("path/to/parent_module/submodule1", "submodule1", True), + ("path/to/parent_module/submodule2", "submodule2", True), + ] + + mock_import_module = MagicMock() + mock_import_module.side_effect = [mock_submodule1, mock_submodule2] + + with patch("app.utils.load_submodules.pkgutil.iter_modules", mock_iter_modules): + with patch( + "app.utils.load_submodules.importlib.import_module", mock_import_module + ): + submodules = load_submodules(parent_module) + + assert submodules == [mock_submodule1, mock_submodule2] + mock_iter_modules.assert_called_once_with(parent_module.__path__) + mock_import_module.assert_any_call("parent_module.submodule1") + mock_import_module.assert_any_call("parent_module.submodule2")