From a780945ef53575dd8263a015c0808f73c3b63976 Mon Sep 17 00:00:00 2001 From: Andrew Borg Date: Sun, 2 Feb 2025 17:43:17 -0500 Subject: [PATCH] feat: Add option for pre and post request hooks --- django_webhook/checks.py | 28 +++++++ django_webhook/settings.py | 8 ++ django_webhook/tasks.py | 11 ++- tests/test_hooks.py | 146 +++++++++++++++++++++++++++++++++++++ 4 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 tests/test_hooks.py diff --git a/django_webhook/checks.py b/django_webhook/checks.py index fc28523..e2ac20c 100644 --- a/django_webhook/checks.py +++ b/django_webhook/checks.py @@ -2,6 +2,7 @@ from django.core.checks import Error, register from .settings import get_settings +import inspect @register() @@ -42,5 +43,32 @@ def warn_about_webhooks_settings(app_configs, **kwargs): id="django_webhook.E03", ) ) + before_request = webhook_settings.get("BEFORE_REQUEST") + if before_request: + before_request_signature = inspect.signature(before_request) + params = before_request_signature.parameters + if set(params.keys()).difference(["webhook", "payload"]) != set(): + errors.append( + Error( + "If set, settings.DJANGO_WEBHOOK.BEFORE_REQUEST must be a function that accepts two arguments.", + hint=f"Function '{before_request_signature.__name__}' takes arguments {params}", + id="django_webhook.E04", + ) + ) + after_request = webhook_settings.get("AFTER_REQUEST") + if after_request: + after_request_signature = inspect.signature(after_request) + params = after_request_signature.parameters + if ( + set(params.keys()).difference(["webhook", "payload", "response"]) + != set() + ): + errors.append( + Error( + "If set, settings.DJANGO_WEBHOOK.AFTER_REQUEST must be a function that accepts three arguments.", + hint=f"Function '{after_request.__name__}' takes arguments {params}", + id="django_webhook.E04", + ) + ) return errors diff --git a/django_webhook/settings.py b/django_webhook/settings.py index a7c1301..3c147bd 100644 --- a/django_webhook/settings.py +++ b/django_webhook/settings.py @@ -20,4 +20,12 @@ def get_settings(): if isinstance(encoder_cls, str): webhook_settings["PAYLOAD_ENCODER_CLASS"] = import_string(encoder_cls) + before_request = webhook_settings.get("BEFORE_REQUEST") + if isinstance(before_request, str): + webhook_settings["BEFORE_REQUEST"] = import_string(before_request) + + after_request = webhook_settings.get("AFTER_REQUEST") + if isinstance(after_request, str): + webhook_settings["AFTER_REQUEST"] = import_string(after_request) + return webhook_settings diff --git a/django_webhook/tasks.py b/django_webhook/tasks.py index 310cba8..2abb8aa 100644 --- a/django_webhook/tasks.py +++ b/django_webhook/tasks.py @@ -37,6 +37,8 @@ def fire_webhook( req = prepare_request(webhook, payload) # type: ignore settings = get_settings() store_events = settings["STORE_EVENTS"] + before_request = settings.get("BEFORE_REQUEST") + after_request = settings.get("AFTER_REQUEST") if store_events: event = WebhookEvent.objects.create( @@ -47,15 +49,22 @@ def fire_webhook( url=webhook.url, topic=topic, ) + if before_request: + before_request(webhook, payload) try: - Session().send(req).raise_for_status() + response = Session().send(req) + response.raise_for_status() if store_events: WebhookEvent.objects.filter(id=event.id).update(status=states.SUCCESS) + if after_request: + after_request(webhook, payload, response) except RequestException as ex: status_code = ex.response.status_code # type: ignore logging.warning(f"Webhook request failed {status_code=}") if store_events: WebhookEvent.objects.filter(id=event.id).update(status=states.FAILURE) + if after_request: + after_request(webhook, payload, ex.response) raise self.retry(exc=ex) diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..05cd6ca --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,146 @@ +import pytest +import json +from freezegun import freeze_time +from unittest.mock import patch, MagicMock + +from django_webhook.tasks import fire_webhook +from django_webhook.test_factories import ( + WebhookFactory, + WebhookSecretFactory, + WebhookTopicFactory, +) +from django.test import override_settings + +pytestmark = pytest.mark.django_db + + +def before_request(): + pass + + +def after_request(): + pass + + +@freeze_time("2012-01-14 03:21:34") +def test_before_request_string(responses): + with patch("tests.test_hooks.before_request") as hook_mock: + with override_settings( + DJANGO_WEBHOOK=dict( + BEFORE_REQUEST="tests.test_hooks.before_request", + ) + ): + webhook = WebhookFactory( + topics=[WebhookTopicFactory(name="tests.User/create")], secrets=[] + ) + responses.post(webhook.url) + WebhookSecretFactory(webhook=webhook, token="Hugh-Clowers-Thompson-Jr") + WebhookSecretFactory(webhook=webhook, token="Augusto-César-Sandino") + payload = dict(hello="world") + fire_webhook.apply((webhook.id, json.dumps(payload))) + hook_mock.assert_called_once() + + +@freeze_time("2012-01-14 03:21:34") +def test_after_request_success_string(responses): + with ( + patch("tests.test_hooks.after_request") as hook_mock, + override_settings( + DJANGO_WEBHOOK=dict( + AFTER_REQUEST="tests.test_hooks.after_request", + ) + ), + ): + webhook = WebhookFactory( + topics=[WebhookTopicFactory(name="tests.User/create")], secrets=[] + ) + responses.post(webhook.url) + WebhookSecretFactory(webhook=webhook, token="Hugh-Clowers-Thompson-Jr") + WebhookSecretFactory(webhook=webhook, token="Augusto-César-Sandino") + payload = dict(hello="world") + fire_webhook.apply((webhook.id, json.dumps(payload))) + hook_mock.assert_called_once() + + +@freeze_time("2012-01-14 03:21:34") +def test_after_request_error_string(responses): + with ( + patch("django_webhook.tasks.Session.send") as response_mock, + patch("tests.test_hooks.after_request") as hook_mock, + override_settings( + DJANGO_WEBHOOK=dict( + AFTER_REQUEST="tests.test_hooks.after_request", + ) + ), + ): + response_mock.status_code = 400 + webhook = WebhookFactory( + topics=[WebhookTopicFactory(name="tests.User/create")], secrets=[] + ) + responses.post(webhook.url) + WebhookSecretFactory(webhook=webhook, token="Hugh-Clowers-Thompson-Jr") + WebhookSecretFactory(webhook=webhook, token="Augusto-César-Sandino") + payload = dict(hello="world") + fire_webhook.apply((webhook.id, json.dumps(payload))) + hook_mock.assert_called_once() + + +@freeze_time("2012-01-14 03:21:34") +def test_before_request_function(responses): + hook_mock = MagicMock() + with override_settings( + DJANGO_WEBHOOK=dict( + BEFORE_REQUEST=hook_mock, + ) + ): + webhook = WebhookFactory( + topics=[WebhookTopicFactory(name="tests.User/create")], secrets=[] + ) + responses.post(webhook.url) + WebhookSecretFactory(webhook=webhook, token="Hugh-Clowers-Thompson-Jr") + WebhookSecretFactory(webhook=webhook, token="Augusto-César-Sandino") + payload = dict(hello="world") + fire_webhook.apply((webhook.id, json.dumps(payload))) + hook_mock.assert_called_once() + + +@freeze_time("2012-01-14 03:21:34") +def test_after_request_success_function(responses): + hook_mock = MagicMock() + with override_settings( + DJANGO_WEBHOOK=dict( + AFTER_REQUEST=hook_mock, + ) + ): + webhook = WebhookFactory( + topics=[WebhookTopicFactory(name="tests.User/create")], secrets=[] + ) + responses.post(webhook.url) + WebhookSecretFactory(webhook=webhook, token="Hugh-Clowers-Thompson-Jr") + WebhookSecretFactory(webhook=webhook, token="Augusto-César-Sandino") + payload = dict(hello="world") + fire_webhook.apply((webhook.id, json.dumps(payload))) + hook_mock.assert_called_once() + + +@freeze_time("2012-01-14 03:21:34") +def test_after_request_error_function(responses): + hook_mock = MagicMock() + with ( + patch("django_webhook.tasks.Session.send") as response_mock, + override_settings( + DJANGO_WEBHOOK=dict( + AFTER_REQUEST=hook_mock, + ) + ), + ): + response_mock.status_code = 400 + webhook = WebhookFactory( + topics=[WebhookTopicFactory(name="tests.User/create")], secrets=[] + ) + responses.post(webhook.url) + WebhookSecretFactory(webhook=webhook, token="Hugh-Clowers-Thompson-Jr") + WebhookSecretFactory(webhook=webhook, token="Augusto-César-Sandino") + payload = dict(hello="world") + fire_webhook.apply((webhook.id, json.dumps(payload))) + hook_mock.assert_called_once()