Skip to content

Commit a905942

Browse files
committed
fix: custom encoding of the webhook payload
1 parent 7568f3d commit a905942

20 files changed

+891
-457
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,6 @@ dmypy.json
110110
# End of https://www.gitignore.io/api/python
111111
*.sqlite3
112112
tests/media
113+
.direnv
114+
115+
tests/settings/media/*.txt

conftest.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
import pytest
22
import responses as responses_lib
3+
from pytest_factoryboy import register
4+
5+
from django_webhook.test_factories import (
6+
WebhookEventFactory,
7+
WebhookFactory,
8+
WebhookSecretFactory,
9+
WebhookTopicFactory,
10+
)
11+
12+
register(WebhookFactory)
13+
register(WebhookEventFactory)
14+
register(WebhookTopicFactory)
15+
register(WebhookSecretFactory)
316

417

518
@pytest.fixture

django_webhook/apps.py

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# pylint: disable=import-outside-toplevel
2+
23
from django.apps import AppConfig
34

45

@@ -7,17 +8,6 @@ class WebhooksConfig(AppConfig):
78
default_auto_field = "django.db.models.AutoField"
89

910
def ready(self):
10-
from django.conf import settings
11-
12-
from .settings import defaults
13-
14-
d = getattr(settings, "DJANGO_WEBHOOK", {})
15-
for k, v in defaults.items():
16-
if k not in d:
17-
d[k] = v
18-
19-
settings.DJANGO_WEBHOOK = d
20-
2111
# pylint: disable=unused-import
2212
import django_webhook.checks
2313
from django_webhook.models import populate_topics_from_settings

django_webhook/checks.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
# pylint: disable=import-outside-toplevel,unused-argument
2-
from django.conf import settings
32
from django.core.checks import Error, register
43

4+
from .settings import get_settings
5+
56

67
@register()
78
def warn_about_webhooks_settings(app_configs, **kwargs):
8-
webhook_settings = getattr(settings, "DJANGO_WEBHOOK")
9+
webhook_settings = get_settings()
910
errors = []
1011
if not webhook_settings:
1112
errors.append(

django_webhook/http.py

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,19 @@
11
import hashlib
22
import hmac
3-
import json
43
from datetime import datetime
5-
from json import JSONEncoder
6-
from typing import cast
74

8-
from django.conf import settings
95
from django.utils import timezone
106
from requests import Request
117

128
from django_webhook.models import Webhook
139

1410

15-
def prepare_request(webhook: Webhook, payload: dict):
11+
def prepare_request(webhook: Webhook, payload: str):
1612
now = timezone.now()
1713
timestamp = int(datetime.timestamp(now))
1814

19-
encoder_cls = cast(
20-
type[JSONEncoder], settings.DJANGO_WEBHOOK["PAYLOAD_ENCODER_CLASS"]
21-
)
2215
signatures = [
23-
sign_payload(payload, secret, timestamp, encoder_cls)
16+
sign_payload(payload, secret, timestamp)
2417
for secret in webhook.secrets.values_list("token", flat=True)
2518
]
2619
headers = {
@@ -33,18 +26,13 @@ def prepare_request(webhook: Webhook, payload: dict):
3326
method="POST",
3427
url=webhook.url,
3528
headers=headers,
36-
data=json.dumps(payload, cls=encoder_cls).encode(),
29+
data=payload.encode(),
3730
)
3831
return r.prepare()
3932

4033

41-
def sign_payload(
42-
payload: dict, secret: str, timestamp: int, encoder_cls: type[JSONEncoder]
43-
):
44-
combined_payload = f"{timestamp}:{json.dumps(payload, cls=encoder_cls)}"
34+
def sign_payload(payload: str, secret: str, timestamp: int):
35+
combined_payload = f"{timestamp}:{payload}"
4536
return hmac.new(
4637
key=secret.encode(), msg=combined_payload.encode(), digestmod=hashlib.sha256
4738
).hexdigest()
48-
49-
50-
# TODO: Test that encoder is swappable

django_webhook/models.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
import uuid
33

44
from celery import states
5-
from django.conf import settings
65
from django.core import validators
76
from django.core.serializers.json import DjangoJSONEncoder
87
from django.db import models
98
from django.db.models.fields import DateTimeField
109

10+
from django_webhook.settings import get_settings
11+
1112
from .validators import validate_topic_model
1213

1314
topic_regex = r"\w+\.\w+\/[create|update|delete]"
@@ -111,7 +112,7 @@ def populate_topics_from_settings():
111112
return
112113
raise ex
113114

114-
webhook_settings = getattr(settings, "DJANGO_WEBHOOK", {})
115+
webhook_settings = get_settings()
115116
enabled_models = webhook_settings.get("MODELS")
116117
if not enabled_models:
117118
return

django_webhook/settings.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
11
from django.core.serializers.json import DjangoJSONEncoder
2+
from django.utils.module_loading import import_string
23

34
defaults = dict(
45
PAYLOAD_ENCODER_CLASS=DjangoJSONEncoder,
56
STORE_EVENTS=True,
67
EVENTS_RETENTION_DAYS=30,
78
USE_CACHE=True,
89
)
10+
11+
12+
def get_settings():
13+
# pylint: disable=redefined-outer-name,import-outside-toplevel
14+
from django.conf import settings
15+
16+
user_defined_settings = getattr(settings, "DJANGO_WEBHOOK", {})
17+
webhook_settings = {**defaults, **user_defined_settings}
18+
19+
encoder_cls = webhook_settings["PAYLOAD_ENCODER_CLASS"]
20+
if isinstance(encoder_cls, str):
21+
webhook_settings["PAYLOAD_ENCODER_CLASS"] = import_string(encoder_cls)
22+
23+
return webhook_settings

django_webhook/signals.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
# pylint: disable=redefined-builtin
2+
import json
23
from datetime import timedelta
34

45
from django.apps import apps
5-
from django.conf import settings
66
from django.db import models
77
from django.db.models.signals import ModelSignal, post_delete, post_save
88
from django.forms import model_to_dict
99

1010
from django_webhook.models import Webhook
1111

12+
from .settings import get_settings
1213
from .tasks import fire_webhook
1314
from .util import cache
1415

@@ -42,15 +43,22 @@ def run(self, sender, created: bool = False, instance=None, **kwargs):
4243

4344
topic = f"{self.model_label}/{action_type}"
4445
webhook_ids = _find_webhooks(topic)
46+
encoder_cls = get_settings()["PAYLOAD_ENCODER_CLASS"]
4547

4648
for id, uuid in webhook_ids:
47-
payload = dict(
48-
topic=topic,
49+
payload_dict = dict(
4950
object=model_dict(instance),
51+
topic=topic,
5052
object_type=self.model_label,
5153
webhook_uuid=str(uuid),
5254
)
53-
fire_webhook.delay(id, payload)
55+
payload = json.dumps(payload_dict, cls=encoder_cls)
56+
fire_webhook.delay(
57+
id,
58+
payload,
59+
topic=topic,
60+
object_type=self.model_label,
61+
)
5462

5563
def connect(self):
5664
self.signal.connect(
@@ -89,7 +97,7 @@ def model_dict(model):
8997

9098

9199
def _active_models():
92-
model_names = settings.DJANGO_WEBHOOK.get("MODELS", [])
100+
model_names = get_settings().get("MODELS", [])
93101
model_classes = []
94102
for name in model_names:
95103
parts = name.split(".")
@@ -108,7 +116,7 @@ def _find_webhooks(topic: str):
108116
"""
109117
In tests and for smaller setups we don't want to cache the query.
110118
"""
111-
if settings.DJANGO_WEBHOOK["USE_CACHE"]:
119+
if get_settings()["USE_CACHE"]:
112120
return _query_webhooks_cached(topic)
113121
return _query_webhooks(topic)
114122

django_webhook/tasks.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1+
import json
12
import logging
23
from datetime import timedelta
34

45
from celery import current_app as app
56
from celery import states
6-
from django.conf import settings
77
from django.utils import timezone
88
from requests import Session
99
from requests.exceptions import RequestException
1010

1111
from django_webhook.models import Webhook, WebhookEvent
1212

1313
from .http import prepare_request
14+
from .settings import get_settings
1415

1516

1617
@app.task(
@@ -21,23 +22,30 @@
2122
retry_backoff_max=60 * 60,
2223
retry_jitter=False,
2324
)
24-
def fire_webhook(self, webhook_id: int, payload: dict):
25+
def fire_webhook(
26+
self,
27+
webhook_id: int,
28+
payload: dict,
29+
topic=None,
30+
object_type=None,
31+
):
2532
webhook = Webhook.objects.get(id=webhook_id)
2633
if not webhook.active:
2734
logging.warning(f"Webhook: {webhook} is inactive and I will not fire it.")
2835
return
2936

30-
req = prepare_request(webhook, payload)
31-
store_events = settings.DJANGO_WEBHOOK["STORE_EVENTS"]
37+
req = prepare_request(webhook, payload) # type: ignore
38+
settings = get_settings()
39+
store_events = settings["STORE_EVENTS"]
3240

3341
if store_events:
3442
event = WebhookEvent.objects.create(
3543
webhook=webhook,
36-
object=payload,
37-
object_type=payload.get("object_type"),
44+
object=json.loads(payload), # type: ignore
45+
object_type=object_type,
3846
status=states.PENDING,
3947
url=webhook.url,
40-
topic=payload.get("topic"),
48+
topic=topic,
4149
)
4250
try:
4351
Session().send(req).raise_for_status()
@@ -63,7 +71,7 @@ def clear_webhook_events():
6371
"""
6472
Clears out old webhook events
6573
"""
66-
days_ago = settings.DJANGO_WEBHOOK["EVENTS_RETENTION_DAYS"]
74+
days_ago = get_settings()["EVENTS_RETENTION_DAYS"]
6775
now = timezone.now()
6876
cutoff_date = now - timedelta(days=days_ago) # type: ignore
6977
qs = WebhookEvent.objects.filter(created__lt=cutoff_date)

django_webhook/validators.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
from django.conf import settings
21
from django.core.exceptions import ValidationError
32

3+
from .settings import get_settings
4+
45

56
def validate_topic_model(value: str):
6-
webhook_settings = getattr(settings, "DJANGO_WEBHOOK", {})
7+
webhook_settings = get_settings()
78
allowed_models = webhook_settings.get("MODELS", [])
89
if not webhook_settings or not allowed_models:
910
raise ValidationError("settings.DJANGO_WEBHOOK.MODELS is empty")

0 commit comments

Comments
 (0)