pgdispatch 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pgdispatch/__init__.py +69 -0
- pgdispatch/__main__.py +5 -0
- pgdispatch/_version.py +1 -0
- pgdispatch/backends/__init__.py +6 -0
- pgdispatch/backends/asyncpg.py +103 -0
- pgdispatch/backends/base.py +142 -0
- pgdispatch/cli.py +420 -0
- pgdispatch/client.py +490 -0
- pgdispatch/config.py +223 -0
- pgdispatch/core/__init__.py +1 -0
- pgdispatch/core/circuit.py +94 -0
- pgdispatch/core/common.py +87 -0
- pgdispatch/core/deliveries.py +216 -0
- pgdispatch/core/endpoints.py +338 -0
- pgdispatch/core/events.py +65 -0
- pgdispatch/core/maintenance.py +121 -0
- pgdispatch/core/matching.py +76 -0
- pgdispatch/core/payload.py +108 -0
- pgdispatch/core/publisher.py +232 -0
- pgdispatch/core/retry.py +90 -0
- pgdispatch/core/secrets.py +138 -0
- pgdispatch/core/signing.py +141 -0
- pgdispatch/django/__init__.py +37 -0
- pgdispatch/django/apps.py +10 -0
- pgdispatch/django/client.py +402 -0
- pgdispatch/django/management/__init__.py +0 -0
- pgdispatch/django/management/commands/__init__.py +0 -0
- pgdispatch/django/management/commands/pgdispatch_worker.py +32 -0
- pgdispatch/django/migrations/0001_initial.py +30 -0
- pgdispatch/django/migrations/__init__.py +0 -0
- pgdispatch/errors.py +43 -0
- pgdispatch/fastapi/__init__.py +23 -0
- pgdispatch/fastapi/router.py +243 -0
- pgdispatch/hooks.py +80 -0
- pgdispatch/metrics.py +80 -0
- pgdispatch/migrations/0001_initial.sql +104 -0
- pgdispatch/migrations/__init__.py +93 -0
- pgdispatch/models.py +208 -0
- pgdispatch/py.typed +0 -0
- pgdispatch/security/__init__.py +1 -0
- pgdispatch/security/ssrf.py +141 -0
- pgdispatch/sqlalchemy.py +130 -0
- pgdispatch/worker/__init__.py +5 -0
- pgdispatch/worker/fetcher.py +183 -0
- pgdispatch/worker/ratelimit.py +47 -0
- pgdispatch/worker/recorder.py +435 -0
- pgdispatch/worker/runner.py +36 -0
- pgdispatch/worker/sender.py +160 -0
- pgdispatch/worker/worker.py +557 -0
- pgdispatch-0.1.0.dist-info/METADATA +329 -0
- pgdispatch-0.1.0.dist-info/RECORD +54 -0
- pgdispatch-0.1.0.dist-info/WHEEL +4 -0
- pgdispatch-0.1.0.dist-info/entry_points.txt +2 -0
- pgdispatch-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Request signing according to the `Standard Webhooks`_ specification.
|
|
2
|
+
|
|
3
|
+
Every request carries three headers:
|
|
4
|
+
|
|
5
|
+
``webhook-id``
|
|
6
|
+
The event id. It is identical across retries, so receivers can deduplicate.
|
|
7
|
+
``webhook-timestamp``
|
|
8
|
+
Unix time of this attempt, in seconds.
|
|
9
|
+
``webhook-signature``
|
|
10
|
+
``v1,<base64 HMAC-SHA256(secret, "{id}.{timestamp}.{body}")>``. During a
|
|
11
|
+
secret rotation several space-separated signatures are sent, one per
|
|
12
|
+
active secret, and a receiver accepts the request if any of them matches.
|
|
13
|
+
|
|
14
|
+
The implementation is a few lines of standard-library code; the test-suite
|
|
15
|
+
checks it against the reference ``standardwebhooks`` package, so receivers can
|
|
16
|
+
use any official Standard Webhooks SDK.
|
|
17
|
+
|
|
18
|
+
.. _Standard Webhooks: https://www.standardwebhooks.com/
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import binascii
|
|
25
|
+
import hashlib
|
|
26
|
+
import hmac
|
|
27
|
+
import secrets
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import Iterable, Mapping
|
|
30
|
+
|
|
31
|
+
from pgdispatch.errors import InvalidSignatureError, ValidationError
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"SECRET_PREFIX",
|
|
35
|
+
"generate_secret",
|
|
36
|
+
"sign",
|
|
37
|
+
"signature_header",
|
|
38
|
+
"validate_secret",
|
|
39
|
+
"verify",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
SECRET_PREFIX = "whsec_" # noqa: S105 - a prefix, not a password
|
|
43
|
+
SIGNATURE_VERSION = "v1"
|
|
44
|
+
DEFAULT_TOLERANCE_SECONDS = 5 * 60
|
|
45
|
+
|
|
46
|
+
_MIN_SECRET_BYTES = 24
|
|
47
|
+
_MAX_SECRET_BYTES = 64
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def generate_secret(num_bytes: int = 32) -> str:
|
|
51
|
+
"""Create a new random signing secret such as ``whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw``."""
|
|
52
|
+
if not _MIN_SECRET_BYTES <= num_bytes <= _MAX_SECRET_BYTES:
|
|
53
|
+
msg = f"secrets must be {_MIN_SECRET_BYTES}-{_MAX_SECRET_BYTES} bytes long"
|
|
54
|
+
raise ValueError(msg)
|
|
55
|
+
return SECRET_PREFIX + base64.b64encode(secrets.token_bytes(num_bytes)).decode("ascii")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_secret(secret: str) -> str:
|
|
59
|
+
"""Return ``secret`` if it is a well-formed ``whsec_`` secret.
|
|
60
|
+
|
|
61
|
+
:raises ValidationError: if it is not.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
key = _decode_secret(secret)
|
|
65
|
+
except ValueError:
|
|
66
|
+
key = b""
|
|
67
|
+
if (
|
|
68
|
+
not secret.startswith(SECRET_PREFIX)
|
|
69
|
+
or not _MIN_SECRET_BYTES <= len(key) <= _MAX_SECRET_BYTES
|
|
70
|
+
):
|
|
71
|
+
msg = (
|
|
72
|
+
f"a signing secret must look like '{SECRET_PREFIX}<base64>' and encode "
|
|
73
|
+
f"{_MIN_SECRET_BYTES}-{_MAX_SECRET_BYTES} random bytes"
|
|
74
|
+
)
|
|
75
|
+
raise ValidationError(msg)
|
|
76
|
+
return secret
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def sign(secret: str, msg_id: str, timestamp: int, body: bytes) -> str:
|
|
80
|
+
"""Return a single ``v1,<signature>`` value for ``body``."""
|
|
81
|
+
signed_content = f"{msg_id}.{timestamp}.".encode() + body
|
|
82
|
+
digest = hmac.new(_decode_secret(secret), signed_content, hashlib.sha256).digest()
|
|
83
|
+
return f"{SIGNATURE_VERSION},{base64.b64encode(digest).decode('ascii')}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def signature_header(secrets_: Iterable[str], msg_id: str, timestamp: int, body: bytes) -> str:
|
|
87
|
+
"""Build the ``webhook-signature`` header value, one signature per active secret."""
|
|
88
|
+
return " ".join(sign(secret, msg_id, timestamp, body) for secret in secrets_)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def verify(
|
|
92
|
+
secret: str,
|
|
93
|
+
headers: Mapping[str, str],
|
|
94
|
+
body: bytes,
|
|
95
|
+
*,
|
|
96
|
+
tolerance: int = DEFAULT_TOLERANCE_SECONDS,
|
|
97
|
+
now: float | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Check a received webhook; raise :class:`InvalidSignatureError` if it is not authentic.
|
|
100
|
+
|
|
101
|
+
Useful in tests and in Python receivers::
|
|
102
|
+
|
|
103
|
+
from pgdispatch import verify_signature
|
|
104
|
+
|
|
105
|
+
verify_signature(secret, request.headers, await request.body())
|
|
106
|
+
"""
|
|
107
|
+
lowered = {name.lower(): value for name, value in headers.items()}
|
|
108
|
+
try:
|
|
109
|
+
msg_id = lowered["webhook-id"]
|
|
110
|
+
raw_timestamp = lowered["webhook-timestamp"]
|
|
111
|
+
received = lowered["webhook-signature"]
|
|
112
|
+
except KeyError as exc:
|
|
113
|
+
msg = f"missing required header {exc.args[0]!r}"
|
|
114
|
+
raise InvalidSignatureError(msg) from None
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
timestamp = int(raw_timestamp)
|
|
118
|
+
except ValueError:
|
|
119
|
+
msg = "invalid webhook-timestamp header"
|
|
120
|
+
raise InvalidSignatureError(msg) from None
|
|
121
|
+
current = time.time() if now is None else now
|
|
122
|
+
if abs(current - timestamp) > tolerance:
|
|
123
|
+
msg = "webhook timestamp is outside the allowed tolerance"
|
|
124
|
+
raise InvalidSignatureError(msg)
|
|
125
|
+
|
|
126
|
+
expected = sign(secret, msg_id, timestamp, body).encode()
|
|
127
|
+
for candidate in received.split():
|
|
128
|
+
version, _, _ = candidate.partition(",")
|
|
129
|
+
if version == SIGNATURE_VERSION and hmac.compare_digest(candidate.encode(), expected):
|
|
130
|
+
return
|
|
131
|
+
msg = "no matching signature found"
|
|
132
|
+
raise InvalidSignatureError(msg)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _decode_secret(secret: str) -> bytes:
|
|
136
|
+
encoded = secret.removeprefix(SECRET_PREFIX)
|
|
137
|
+
try:
|
|
138
|
+
return base64.b64decode(encoded, validate=True)
|
|
139
|
+
except binascii.Error as exc:
|
|
140
|
+
msg = "secret is not valid base64"
|
|
141
|
+
raise ValueError(msg) from exc
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Django integration.
|
|
2
|
+
|
|
3
|
+
Add the app and publish events inside ``transaction.atomic()``::
|
|
4
|
+
|
|
5
|
+
INSTALLED_APPS = [..., "pgdispatch.django"]
|
|
6
|
+
PGDISPATCH = {"concurrency": 50} # optional, any WebhooksConfig field
|
|
7
|
+
|
|
8
|
+
from pgdispatch.django import webhooks
|
|
9
|
+
|
|
10
|
+
with transaction.atomic():
|
|
11
|
+
order.save()
|
|
12
|
+
webhooks.publish("order.shipped", {"id": order.id}, owner_id=order.shop_id)
|
|
13
|
+
|
|
14
|
+
``python manage.py migrate`` creates the tables and ``python manage.py
|
|
15
|
+
pgdispatch_worker`` delivers the webhooks.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from pgdispatch.django.client import DjangoWebhooks
|
|
24
|
+
|
|
25
|
+
webhooks: DjangoWebhooks
|
|
26
|
+
|
|
27
|
+
__all__ = ["webhooks"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def __getattr__(name: str) -> Any:
|
|
31
|
+
# Lazy, so that importing this package does not require configured settings.
|
|
32
|
+
if name == "webhooks":
|
|
33
|
+
from pgdispatch.django.client import get_webhooks
|
|
34
|
+
|
|
35
|
+
return get_webhooks()
|
|
36
|
+
msg = f"module {__name__!r} has no attribute {name!r}"
|
|
37
|
+
raise AttributeError(msg)
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""The synchronous Django facade: ``pgdispatch.django.webhooks``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Iterable, Mapping
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from functools import cache
|
|
10
|
+
from typing import Any, TypeVar
|
|
11
|
+
|
|
12
|
+
import asyncpg
|
|
13
|
+
from django.conf import settings
|
|
14
|
+
from django.db import connections, transaction
|
|
15
|
+
from django.db.backends.base.base import BaseDatabaseWrapper
|
|
16
|
+
|
|
17
|
+
from pgdispatch.backends.asyncpg import WORKER_POOL_SIZE
|
|
18
|
+
from pgdispatch.backends.base import Operation, Row, run_sync, to_pyformat_style
|
|
19
|
+
from pgdispatch.config import WebhooksConfig
|
|
20
|
+
from pgdispatch.core import deliveries as delivery_ops
|
|
21
|
+
from pgdispatch.core import endpoints as endpoint_ops
|
|
22
|
+
from pgdispatch.core import events as event_ops
|
|
23
|
+
from pgdispatch.core import publisher
|
|
24
|
+
from pgdispatch.core.common import UNSET, OwnerFilter, Unset
|
|
25
|
+
from pgdispatch.core.maintenance import QueueStats, queue_stats
|
|
26
|
+
from pgdispatch.core.secrets import SecretStore, fernet_cipher
|
|
27
|
+
from pgdispatch.errors import ConfigurationError
|
|
28
|
+
from pgdispatch.hooks import Hooks
|
|
29
|
+
from pgdispatch.metrics import get_metrics
|
|
30
|
+
from pgdispatch.migrations import drop_schema as drop_schema_operation
|
|
31
|
+
from pgdispatch.migrations import migrate
|
|
32
|
+
from pgdispatch.models import Attempt, Delivery, DeliveryStatus, Endpoint, Event, PublishResult
|
|
33
|
+
from pgdispatch.worker.worker import Worker
|
|
34
|
+
|
|
35
|
+
__all__ = ["DjangoWebhooks", "drop_schema", "get_webhooks", "migrate_schema"]
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DjangoExecutor:
|
|
41
|
+
"""Runs statements through a Django database connection."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, connection: BaseDatabaseWrapper) -> None:
|
|
44
|
+
if connection.vendor != "postgresql":
|
|
45
|
+
msg = (
|
|
46
|
+
f"pgdispatch requires PostgreSQL, "
|
|
47
|
+
f"but database {connection.alias!r} is {connection.vendor}"
|
|
48
|
+
)
|
|
49
|
+
raise ConfigurationError(msg)
|
|
50
|
+
self._connection = connection
|
|
51
|
+
|
|
52
|
+
def fetch(self, sql: str, params: Mapping[str, Any]) -> list[Row]:
|
|
53
|
+
with self._connection.cursor() as cursor:
|
|
54
|
+
cursor.execute(to_pyformat_style(sql), dict(params))
|
|
55
|
+
if cursor.description is None:
|
|
56
|
+
return []
|
|
57
|
+
names = [column[0] for column in cursor.description]
|
|
58
|
+
return [dict(zip(names, row, strict=True)) for row in cursor.fetchall()]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DjangoWebhooks:
|
|
62
|
+
"""Blocking counterpart of :class:`pgdispatch.Webhooks` for Django projects.
|
|
63
|
+
|
|
64
|
+
Every call runs in ``transaction.atomic(using=...)``, which becomes a
|
|
65
|
+
savepoint when you are already inside a transaction, so ``publish`` joins
|
|
66
|
+
your business transaction.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, config: WebhooksConfig | None = None, *, using: str = "default") -> None:
|
|
70
|
+
self.config = config or WebhooksConfig()
|
|
71
|
+
self.using = using
|
|
72
|
+
self.hooks = Hooks()
|
|
73
|
+
keys = [key.get_secret_value() for key in self.config.secret_encryption_keys]
|
|
74
|
+
self._secrets = SecretStore(fernet_cipher(keys) if keys else None)
|
|
75
|
+
self._metrics = get_metrics()
|
|
76
|
+
self._url_policy = endpoint_ops.UrlPolicy(
|
|
77
|
+
allow_http=self.config.allow_http, allow_private_ips=self.config.allow_private_ips
|
|
78
|
+
)
|
|
79
|
+
self.endpoints = DjangoEndpoints(self)
|
|
80
|
+
self.deliveries = DjangoDeliveries(self)
|
|
81
|
+
self.events = DjangoEvents(self)
|
|
82
|
+
|
|
83
|
+
def publish(
|
|
84
|
+
self,
|
|
85
|
+
event_type: str,
|
|
86
|
+
payload: Any,
|
|
87
|
+
*,
|
|
88
|
+
owner_id: str | None = None,
|
|
89
|
+
ordering_key: str | None = None,
|
|
90
|
+
idempotency_key: str | None = None,
|
|
91
|
+
using: str | None = None,
|
|
92
|
+
) -> PublishResult:
|
|
93
|
+
"""Record an event in the current transaction; see :meth:`pgdispatch.Webhooks.publish`."""
|
|
94
|
+
result = self._run(
|
|
95
|
+
publisher.publish(
|
|
96
|
+
event_type=event_type,
|
|
97
|
+
payload=payload,
|
|
98
|
+
owner_id=owner_id,
|
|
99
|
+
ordering_key=ordering_key,
|
|
100
|
+
idempotency_key=idempotency_key,
|
|
101
|
+
channel=self.config.notify_channel,
|
|
102
|
+
max_payload_bytes=self.config.max_payload_bytes,
|
|
103
|
+
initial_delay=self.config.retry_schedule[0],
|
|
104
|
+
),
|
|
105
|
+
using=using,
|
|
106
|
+
)
|
|
107
|
+
if not result.duplicate:
|
|
108
|
+
self._metrics.events_published.labels(event_type).inc()
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
def status(self) -> QueueStats:
|
|
112
|
+
return self._run(queue_stats(worker_ttl=self.config.heartbeat_interval * 3))
|
|
113
|
+
|
|
114
|
+
def worker(self, **overrides: Any) -> Worker:
|
|
115
|
+
"""A :class:`Worker` connected to the same database (used by ``pgdispatch_worker``)."""
|
|
116
|
+
config = (
|
|
117
|
+
WebhooksConfig(**{**self.config.model_dump(), **overrides})
|
|
118
|
+
if overrides
|
|
119
|
+
else self.config
|
|
120
|
+
)
|
|
121
|
+
connect_kwargs = asyncpg_connect_kwargs(settings.DATABASES[self.using])
|
|
122
|
+
|
|
123
|
+
async def create_pool() -> asyncpg.Pool:
|
|
124
|
+
pool: asyncpg.Pool = await asyncpg.create_pool(
|
|
125
|
+
min_size=1, max_size=WORKER_POOL_SIZE, **connect_kwargs
|
|
126
|
+
)
|
|
127
|
+
return pool
|
|
128
|
+
|
|
129
|
+
return Worker(
|
|
130
|
+
create_pool, config, hooks=self.hooks, metrics=self._metrics, secret_store=self._secrets
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _run(self, operation: Operation[T], *, using: str | None = None) -> T:
|
|
134
|
+
alias = using or self.using
|
|
135
|
+
with transaction.atomic(using=alias):
|
|
136
|
+
return run_sync(
|
|
137
|
+
operation, DjangoExecutor(connections[alias]), schema=self.config.db_schema
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class DjangoEndpoints:
|
|
142
|
+
def __init__(self, webhooks: DjangoWebhooks) -> None:
|
|
143
|
+
self._webhooks = webhooks
|
|
144
|
+
|
|
145
|
+
def create(
|
|
146
|
+
self,
|
|
147
|
+
url: str,
|
|
148
|
+
*,
|
|
149
|
+
event_types: Iterable[str] = ("*",),
|
|
150
|
+
owner_id: str | None = None,
|
|
151
|
+
description: str | None = None,
|
|
152
|
+
headers: Mapping[str, str] | None = None,
|
|
153
|
+
rate_limit_rps: float | None = None,
|
|
154
|
+
secret: str | None = None,
|
|
155
|
+
enabled: bool = True,
|
|
156
|
+
) -> Endpoint:
|
|
157
|
+
w = self._webhooks
|
|
158
|
+
return w._run(
|
|
159
|
+
endpoint_ops.create_endpoint(
|
|
160
|
+
url=url,
|
|
161
|
+
event_types=event_types,
|
|
162
|
+
owner_id=owner_id,
|
|
163
|
+
description=description,
|
|
164
|
+
headers=headers,
|
|
165
|
+
rate_limit_rps=rate_limit_rps,
|
|
166
|
+
secret=secret,
|
|
167
|
+
enabled=enabled,
|
|
168
|
+
url_policy=w._url_policy,
|
|
169
|
+
secret_store=w._secrets,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def get(self, endpoint_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> Endpoint:
|
|
174
|
+
return self._webhooks._run(endpoint_ops.get_endpoint(endpoint_id, owner_id=owner_id))
|
|
175
|
+
|
|
176
|
+
def list(
|
|
177
|
+
self,
|
|
178
|
+
*,
|
|
179
|
+
owner_id: OwnerFilter = UNSET,
|
|
180
|
+
enabled: bool | None = None,
|
|
181
|
+
limit: int = 100,
|
|
182
|
+
after: uuid.UUID | None = None,
|
|
183
|
+
) -> builtins.list[Endpoint]:
|
|
184
|
+
return self._webhooks._run(
|
|
185
|
+
endpoint_ops.list_endpoints(
|
|
186
|
+
owner_id=owner_id, enabled=enabled, limit=limit, after=after
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def update(
|
|
191
|
+
self,
|
|
192
|
+
endpoint_id: uuid.UUID,
|
|
193
|
+
*,
|
|
194
|
+
url: str | Unset = UNSET,
|
|
195
|
+
event_types: Iterable[str] | Unset = UNSET,
|
|
196
|
+
description: str | Unset | None = UNSET,
|
|
197
|
+
headers: Mapping[str, str] | Unset = UNSET,
|
|
198
|
+
rate_limit_rps: float | Unset | None = UNSET,
|
|
199
|
+
owner_id: OwnerFilter = UNSET,
|
|
200
|
+
) -> Endpoint:
|
|
201
|
+
w = self._webhooks
|
|
202
|
+
return w._run(
|
|
203
|
+
endpoint_ops.update_endpoint(
|
|
204
|
+
endpoint_id,
|
|
205
|
+
owner_id=owner_id,
|
|
206
|
+
url=url,
|
|
207
|
+
event_types=event_types,
|
|
208
|
+
description=description,
|
|
209
|
+
headers=headers,
|
|
210
|
+
rate_limit_rps=rate_limit_rps,
|
|
211
|
+
url_policy=w._url_policy,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def enable(self, endpoint_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> Endpoint:
|
|
216
|
+
return self._webhooks._run(endpoint_ops.enable_endpoint(endpoint_id, owner_id=owner_id))
|
|
217
|
+
|
|
218
|
+
def disable(
|
|
219
|
+
self, endpoint_id: uuid.UUID, *, reason: str | None = None, owner_id: OwnerFilter = UNSET
|
|
220
|
+
) -> Endpoint:
|
|
221
|
+
return self._webhooks._run(
|
|
222
|
+
endpoint_ops.disable_endpoint(endpoint_id, reason=reason, owner_id=owner_id)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def delete(self, endpoint_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> None:
|
|
226
|
+
self._webhooks._run(endpoint_ops.delete_endpoint(endpoint_id, owner_id=owner_id))
|
|
227
|
+
|
|
228
|
+
def rotate_secret(
|
|
229
|
+
self,
|
|
230
|
+
endpoint_id: uuid.UUID,
|
|
231
|
+
*,
|
|
232
|
+
grace_period: timedelta = timedelta(hours=24),
|
|
233
|
+
secret: str | None = None,
|
|
234
|
+
owner_id: OwnerFilter = UNSET,
|
|
235
|
+
) -> Endpoint:
|
|
236
|
+
w = self._webhooks
|
|
237
|
+
return w._run(
|
|
238
|
+
endpoint_ops.rotate_secret(
|
|
239
|
+
endpoint_id,
|
|
240
|
+
grace_period=grace_period,
|
|
241
|
+
secret=secret,
|
|
242
|
+
owner_id=owner_id,
|
|
243
|
+
secret_store=w._secrets,
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def get_secret(self, endpoint_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> str:
|
|
248
|
+
w = self._webhooks
|
|
249
|
+
return w._run(
|
|
250
|
+
endpoint_ops.get_secret(endpoint_id, owner_id=owner_id, secret_store=w._secrets)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def send_test(self, endpoint_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> PublishResult:
|
|
254
|
+
w = self._webhooks
|
|
255
|
+
return w._run(
|
|
256
|
+
publisher.send_test(endpoint_id, owner_id=owner_id, channel=w.config.notify_channel)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class DjangoDeliveries:
|
|
261
|
+
def __init__(self, webhooks: DjangoWebhooks) -> None:
|
|
262
|
+
self._webhooks = webhooks
|
|
263
|
+
|
|
264
|
+
def list(
|
|
265
|
+
self,
|
|
266
|
+
*,
|
|
267
|
+
endpoint_id: uuid.UUID | None = None,
|
|
268
|
+
event_id: uuid.UUID | None = None,
|
|
269
|
+
status: DeliveryStatus | str | None = None,
|
|
270
|
+
since: datetime | None = None,
|
|
271
|
+
before: int | None = None,
|
|
272
|
+
limit: int = 50,
|
|
273
|
+
owner_id: OwnerFilter = UNSET,
|
|
274
|
+
) -> builtins.list[Delivery]:
|
|
275
|
+
return self._webhooks._run(
|
|
276
|
+
delivery_ops.list_deliveries(
|
|
277
|
+
endpoint_id=endpoint_id,
|
|
278
|
+
event_id=event_id,
|
|
279
|
+
status=status,
|
|
280
|
+
since=since,
|
|
281
|
+
before=before,
|
|
282
|
+
limit=limit,
|
|
283
|
+
owner_id=owner_id,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def get(self, delivery_id: int, *, owner_id: OwnerFilter = UNSET) -> Delivery:
|
|
288
|
+
return self._webhooks._run(delivery_ops.get_delivery(delivery_id, owner_id=owner_id))
|
|
289
|
+
|
|
290
|
+
def attempts(
|
|
291
|
+
self, delivery_id: int, *, owner_id: OwnerFilter = UNSET
|
|
292
|
+
) -> builtins.list[Attempt]:
|
|
293
|
+
return self._webhooks._run(delivery_ops.list_attempts(delivery_id, owner_id=owner_id))
|
|
294
|
+
|
|
295
|
+
def retry(self, delivery_id: int, *, owner_id: OwnerFilter = UNSET) -> Delivery:
|
|
296
|
+
w = self._webhooks
|
|
297
|
+
return w._run(
|
|
298
|
+
delivery_ops.retry_delivery(
|
|
299
|
+
delivery_id, owner_id=owner_id, channel=w.config.notify_channel
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def retry_all(
|
|
304
|
+
self,
|
|
305
|
+
*,
|
|
306
|
+
endpoint_id: uuid.UUID | None = None,
|
|
307
|
+
status: DeliveryStatus | str = DeliveryStatus.FAILED,
|
|
308
|
+
since: datetime | None = None,
|
|
309
|
+
owner_id: OwnerFilter = UNSET,
|
|
310
|
+
) -> int:
|
|
311
|
+
w = self._webhooks
|
|
312
|
+
return w._run(
|
|
313
|
+
delivery_ops.retry_deliveries(
|
|
314
|
+
endpoint_id=endpoint_id,
|
|
315
|
+
status=status,
|
|
316
|
+
since=since,
|
|
317
|
+
owner_id=owner_id,
|
|
318
|
+
channel=w.config.notify_channel,
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def cancel(self, delivery_id: int, *, owner_id: OwnerFilter = UNSET) -> Delivery:
|
|
323
|
+
return self._webhooks._run(delivery_ops.cancel_delivery(delivery_id, owner_id=owner_id))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class DjangoEvents:
|
|
327
|
+
def __init__(self, webhooks: DjangoWebhooks) -> None:
|
|
328
|
+
self._webhooks = webhooks
|
|
329
|
+
|
|
330
|
+
def get(self, event_id: uuid.UUID, *, owner_id: OwnerFilter = UNSET) -> Event:
|
|
331
|
+
return self._webhooks._run(event_ops.get_event(event_id, owner_id=owner_id))
|
|
332
|
+
|
|
333
|
+
def list(
|
|
334
|
+
self,
|
|
335
|
+
*,
|
|
336
|
+
owner_id: OwnerFilter = UNSET,
|
|
337
|
+
event_type: str | None = None,
|
|
338
|
+
since: datetime | None = None,
|
|
339
|
+
before: uuid.UUID | None = None,
|
|
340
|
+
limit: int = 50,
|
|
341
|
+
) -> builtins.list[Event]:
|
|
342
|
+
return self._webhooks._run(
|
|
343
|
+
event_ops.list_events(
|
|
344
|
+
owner_id=owner_id, event_type=event_type, since=since, before=before, limit=limit
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def replay(
|
|
349
|
+
self,
|
|
350
|
+
event_id: uuid.UUID,
|
|
351
|
+
*,
|
|
352
|
+
endpoint_id: uuid.UUID | None = None,
|
|
353
|
+
owner_id: OwnerFilter = UNSET,
|
|
354
|
+
) -> builtins.list[int]:
|
|
355
|
+
w = self._webhooks
|
|
356
|
+
return w._run(
|
|
357
|
+
publisher.replay(
|
|
358
|
+
event_id,
|
|
359
|
+
endpoint_id=endpoint_id,
|
|
360
|
+
owner_id=owner_id,
|
|
361
|
+
channel=w.config.notify_channel,
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@cache
|
|
367
|
+
def get_webhooks() -> DjangoWebhooks:
|
|
368
|
+
"""The project-wide instance, configured from ``settings.PGDISPATCH``."""
|
|
369
|
+
options = dict(getattr(settings, "PGDISPATCH", {}))
|
|
370
|
+
using = options.pop("database", "default")
|
|
371
|
+
return DjangoWebhooks(WebhooksConfig(**options), using=using)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def migrate_schema(connection: BaseDatabaseWrapper, *, target: int | None = None) -> list[int]:
|
|
375
|
+
"""Apply pgdispatch SQL migrations through a Django connection."""
|
|
376
|
+
return run_sync(
|
|
377
|
+
migrate(target=target), DjangoExecutor(connection), schema=get_webhooks().config.db_schema
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def drop_schema(connection: BaseDatabaseWrapper) -> None:
|
|
382
|
+
run_sync(
|
|
383
|
+
drop_schema_operation(), DjangoExecutor(connection), schema=get_webhooks().config.db_schema
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def asyncpg_connect_kwargs(database: Mapping[str, Any]) -> dict[str, Any]:
|
|
388
|
+
"""Translate a Django ``DATABASES`` entry into asyncpg connection arguments."""
|
|
389
|
+
if "postgresql" not in database.get("ENGINE", ""):
|
|
390
|
+
msg = f"pgdispatch requires PostgreSQL, got ENGINE={database.get('ENGINE')!r}"
|
|
391
|
+
raise ConfigurationError(msg)
|
|
392
|
+
options = database.get("OPTIONS", {})
|
|
393
|
+
kwargs: dict[str, Any] = {
|
|
394
|
+
"host": database.get("HOST") or None,
|
|
395
|
+
"port": int(database["PORT"]) if database.get("PORT") else None,
|
|
396
|
+
"user": database.get("USER") or None,
|
|
397
|
+
"password": database.get("PASSWORD") or None,
|
|
398
|
+
"database": database.get("NAME") or None,
|
|
399
|
+
}
|
|
400
|
+
if options.get("sslmode"):
|
|
401
|
+
kwargs["ssl"] = options["sslmode"]
|
|
402
|
+
return {key: value for key, value in kwargs.items() if value is not None}
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from django.core.management.base import BaseCommand, CommandParser
|
|
8
|
+
|
|
9
|
+
from pgdispatch.django.client import get_webhooks
|
|
10
|
+
from pgdispatch.worker.runner import run_until_signalled
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Command(BaseCommand):
|
|
14
|
+
help = "Deliver pgdispatch webhooks until interrupted (SIGINT/SIGTERM)."
|
|
15
|
+
|
|
16
|
+
def add_arguments(self, parser: CommandParser) -> None:
|
|
17
|
+
parser.add_argument("--concurrency", type=int, help="Simultaneous HTTP requests.")
|
|
18
|
+
parser.add_argument("--batch-size", type=int, help="Deliveries claimed per query.")
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--metrics-port", type=int, help="Serve Prometheus metrics on this port."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
24
|
+
if not logging.getLogger().handlers:
|
|
25
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
26
|
+
overrides = {
|
|
27
|
+
name: options[name]
|
|
28
|
+
for name in ("concurrency", "batch_size")
|
|
29
|
+
if options.get(name) is not None
|
|
30
|
+
}
|
|
31
|
+
worker = get_webhooks().worker(**overrides)
|
|
32
|
+
asyncio.run(run_until_signalled(worker, metrics_port=options.get("metrics_port")))
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Create the pgdispatch schema.
|
|
2
|
+
|
|
3
|
+
The tables are defined once, in pgdispatch's own SQL migrations; this Django
|
|
4
|
+
migration simply runs them, so ``migrate`` and ``pgdispatch migrate`` always
|
|
5
|
+
produce the same schema. Future versions add a Django migration per SQL one.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from django.db import migrations
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def forwards(apps: Any, schema_editor: Any) -> None:
|
|
16
|
+
from pgdispatch.django.client import migrate_schema
|
|
17
|
+
|
|
18
|
+
migrate_schema(schema_editor.connection, target=1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def backwards(apps: Any, schema_editor: Any) -> None:
|
|
22
|
+
from pgdispatch.django.client import drop_schema
|
|
23
|
+
|
|
24
|
+
drop_schema(schema_editor.connection)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Migration(migrations.Migration):
|
|
28
|
+
initial = True
|
|
29
|
+
dependencies = []
|
|
30
|
+
operations = [migrations.RunPython(forwards, backwards, elidable=False)]
|
|
File without changes
|