full-event-hub 1.1.4__tar.gz
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.
- full_event_hub-1.1.4/PKG-INFO +91 -0
- full_event_hub-1.1.4/README.md +64 -0
- full_event_hub-1.1.4/pyproject.toml +46 -0
- full_event_hub-1.1.4/setup.cfg +4 -0
- full_event_hub-1.1.4/src/event_hub/__init__.py +37 -0
- full_event_hub-1.1.4/src/event_hub/client.py +266 -0
- full_event_hub-1.1.4/src/event_hub/py.typed +0 -0
- full_event_hub-1.1.4/src/event_hub/signature.py +53 -0
- full_event_hub-1.1.4/src/full_event_hub.egg-info/PKG-INFO +91 -0
- full_event_hub-1.1.4/src/full_event_hub.egg-info/SOURCES.txt +12 -0
- full_event_hub-1.1.4/src/full_event_hub.egg-info/dependency_links.txt +1 -0
- full_event_hub-1.1.4/src/full_event_hub.egg-info/requires.txt +2 -0
- full_event_hub-1.1.4/src/full_event_hub.egg-info/top_level.txt +1 -0
- full_event_hub-1.1.4/tests/test_signature.py +74 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: full-event-hub
|
|
3
|
+
Version: 1.1.4
|
|
4
|
+
Summary: Zero-dependency Python client for the event-hub webhook fan-out service
|
|
5
|
+
Author-email: Ravi Leal <ravi@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ravileal/event-hub
|
|
8
|
+
Project-URL: Repository, https://github.com/ravileal/event-hub
|
|
9
|
+
Project-URL: Issues, https://github.com/ravileal/event-hub/issues
|
|
10
|
+
Keywords: event-hub,webhooks,hmac,fan-out,events,sdk
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
|
|
28
|
+
# full-event-hub (Python client)
|
|
29
|
+
|
|
30
|
+
Zero-dependency Python client for [event-hub](https://github.com/ravileal/event-hub) — a
|
|
31
|
+
webhook fan-out service (ingest, fan-out to N subscribers, HMAC signing, retry/backoff,
|
|
32
|
+
attempts, retention).
|
|
33
|
+
|
|
34
|
+
Only the standard library is used (`urllib.request`, `json`, `hmac`, `hashlib`). The
|
|
35
|
+
package version matches the hub/image version (`ghcr.io/ravileal/event-hub`); because
|
|
36
|
+
they move together, the client sends the English `X-Idempotency` header that hubs
|
|
37
|
+
since 1.1.0 read, while a hub older than 1.1.0 ignores it.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install full-event-hub
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Publish an event
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from event_hub import EventHubClient
|
|
49
|
+
|
|
50
|
+
hub = EventHubClient("http://127.0.0.1:18295")
|
|
51
|
+
|
|
52
|
+
hub.create_topic("orders", description="order lifecycle")
|
|
53
|
+
hub.create_subscriber("orders", "billing",
|
|
54
|
+
url="http://localhost:9000/hook", secret="s3cr3t",
|
|
55
|
+
max_attempts=3, backoff_ms=200)
|
|
56
|
+
|
|
57
|
+
resp = hub.publish_event("orders", {"order_id": 1}, idempotency_key="order-1")
|
|
58
|
+
print(resp.status, resp.body) # 202 {'id': 1, 'deliveries_created': 1, 'duplicado': False}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`publish_event` sends the idempotency key as `X-Idempotency` (the header the hub
|
|
62
|
+
reads since 1.1.0; it falls back to the legacy `X-Idempotencia`). Use
|
|
63
|
+
`sign(secret, raw_body)` to sign the exact bytes you send.
|
|
64
|
+
|
|
65
|
+
## Verify an incoming delivery
|
|
66
|
+
|
|
67
|
+
event-hub signs every delivery with HMAC-SHA256 of the **raw** body using the
|
|
68
|
+
subscriber secret. Pass the exact bytes received; re-serializing JSON breaks the HMAC.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from event_hub import verify_delivery_signature
|
|
72
|
+
|
|
73
|
+
# in your webhook receiver:
|
|
74
|
+
valid = verify_delivery_signature(
|
|
75
|
+
secret, raw_body, request.headers["X-Event-Hub-Signature"]
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`verify_delivery_signature` returns `False` for a missing header, a wrong `sha256=`
|
|
80
|
+
prefix, or a non-matching digest, and compares in constant time
|
|
81
|
+
(`hmac.compare_digest`).
|
|
82
|
+
|
|
83
|
+
## Notes
|
|
84
|
+
|
|
85
|
+
- Idempotency header is **`X-Idempotency`** (English) since 1.1.0; the legacy
|
|
86
|
+
**`X-Idempotencia`** is still accepted for old producers but new code should not
|
|
87
|
+
send it.
|
|
88
|
+
- Deliveries carry `X-Event-Hub-Event`, `X-Event-Hub-Attempt` and
|
|
89
|
+
`X-Event-Hub-Signature` (`sha256=<hex>`).
|
|
90
|
+
|
|
91
|
+
MIT licensed.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# full-event-hub (Python client)
|
|
2
|
+
|
|
3
|
+
Zero-dependency Python client for [event-hub](https://github.com/ravileal/event-hub) — a
|
|
4
|
+
webhook fan-out service (ingest, fan-out to N subscribers, HMAC signing, retry/backoff,
|
|
5
|
+
attempts, retention).
|
|
6
|
+
|
|
7
|
+
Only the standard library is used (`urllib.request`, `json`, `hmac`, `hashlib`). The
|
|
8
|
+
package version matches the hub/image version (`ghcr.io/ravileal/event-hub`); because
|
|
9
|
+
they move together, the client sends the English `X-Idempotency` header that hubs
|
|
10
|
+
since 1.1.0 read, while a hub older than 1.1.0 ignores it.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install full-event-hub
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Publish an event
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from event_hub import EventHubClient
|
|
22
|
+
|
|
23
|
+
hub = EventHubClient("http://127.0.0.1:18295")
|
|
24
|
+
|
|
25
|
+
hub.create_topic("orders", description="order lifecycle")
|
|
26
|
+
hub.create_subscriber("orders", "billing",
|
|
27
|
+
url="http://localhost:9000/hook", secret="s3cr3t",
|
|
28
|
+
max_attempts=3, backoff_ms=200)
|
|
29
|
+
|
|
30
|
+
resp = hub.publish_event("orders", {"order_id": 1}, idempotency_key="order-1")
|
|
31
|
+
print(resp.status, resp.body) # 202 {'id': 1, 'deliveries_created': 1, 'duplicado': False}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`publish_event` sends the idempotency key as `X-Idempotency` (the header the hub
|
|
35
|
+
reads since 1.1.0; it falls back to the legacy `X-Idempotencia`). Use
|
|
36
|
+
`sign(secret, raw_body)` to sign the exact bytes you send.
|
|
37
|
+
|
|
38
|
+
## Verify an incoming delivery
|
|
39
|
+
|
|
40
|
+
event-hub signs every delivery with HMAC-SHA256 of the **raw** body using the
|
|
41
|
+
subscriber secret. Pass the exact bytes received; re-serializing JSON breaks the HMAC.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from event_hub import verify_delivery_signature
|
|
45
|
+
|
|
46
|
+
# in your webhook receiver:
|
|
47
|
+
valid = verify_delivery_signature(
|
|
48
|
+
secret, raw_body, request.headers["X-Event-Hub-Signature"]
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`verify_delivery_signature` returns `False` for a missing header, a wrong `sha256=`
|
|
53
|
+
prefix, or a non-matching digest, and compares in constant time
|
|
54
|
+
(`hmac.compare_digest`).
|
|
55
|
+
|
|
56
|
+
## Notes
|
|
57
|
+
|
|
58
|
+
- Idempotency header is **`X-Idempotency`** (English) since 1.1.0; the legacy
|
|
59
|
+
**`X-Idempotencia`** is still accepted for old producers but new code should not
|
|
60
|
+
send it.
|
|
61
|
+
- Deliveries carry `X-Event-Hub-Event`, `X-Event-Hub-Attempt` and
|
|
62
|
+
`X-Event-Hub-Signature` (`sha256=<hex>`).
|
|
63
|
+
|
|
64
|
+
MIT licensed.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "full-event-hub"
|
|
7
|
+
version = "1.1.4"
|
|
8
|
+
description = "Zero-dependency Python client for the event-hub webhook fan-out service"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Ravi Leal", email = "ravi@example.com" }]
|
|
13
|
+
keywords = ["event-hub", "webhooks", "hmac", "fan-out", "events", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
# The suite uses only the stdlib (unittest), so no test dependency is required.
|
|
33
|
+
# This extra exists so tooling can target an explicit test set without pulling
|
|
34
|
+
# anything heavy; the runtime `dependencies` above stay empty.
|
|
35
|
+
test = []
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/ravileal/event-hub"
|
|
39
|
+
Repository = "https://github.com/ravileal/event-hub"
|
|
40
|
+
Issues = "https://github.com/ravileal/event-hub/issues"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
where = ["src"]
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.package-data]
|
|
46
|
+
event_hub = ["py.typed"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""event-hub — zero-dependency Python client.
|
|
2
|
+
|
|
3
|
+
from event_hub import EventHubClient, verify_delivery_signature
|
|
4
|
+
|
|
5
|
+
hub = EventHubClient("http://localhost:18295")
|
|
6
|
+
hub.create_topic("orders")
|
|
7
|
+
hub.create_subscriber("orders", "billing", url="http://localhost:9000/hook", secret="s3cr3t")
|
|
8
|
+
hub.publish_event("orders", {"id": 1}, idempotency_key="order-1")
|
|
9
|
+
|
|
10
|
+
# in your webhook receiver:
|
|
11
|
+
ok = verify_delivery_signature(secret, raw_body, request.headers["X-Event-Hub-Signature"])
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .client import (
|
|
15
|
+
IDEMPOTENCY_HEADER,
|
|
16
|
+
LEGACY_IDEMPOTENCY_HEADER,
|
|
17
|
+
EventHubClient,
|
|
18
|
+
EventHubError,
|
|
19
|
+
HTTPError,
|
|
20
|
+
Response,
|
|
21
|
+
)
|
|
22
|
+
from .signature import SIGNATURE_PREFIX, sign, verify_delivery_signature
|
|
23
|
+
|
|
24
|
+
__version__ = "0.0.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"EventHubClient",
|
|
28
|
+
"EventHubError",
|
|
29
|
+
"HTTPError",
|
|
30
|
+
"Response",
|
|
31
|
+
"IDEMPOTENCY_HEADER",
|
|
32
|
+
"LEGACY_IDEMPOTENCY_HEADER",
|
|
33
|
+
"SIGNATURE_PREFIX",
|
|
34
|
+
"sign",
|
|
35
|
+
"verify_delivery_signature",
|
|
36
|
+
"__version__",
|
|
37
|
+
]
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Zero-dependency client for the event-hub HTTP API.
|
|
2
|
+
|
|
3
|
+
Uses only the stdlib (``urllib.request`` + ``json``). Every method returns a
|
|
4
|
+
:class:`Response` (``status``, ``headers``, ``body``, ``raw``) so callers can assert on
|
|
5
|
+
exact status codes — the hub uses 201/202/200/404/401 meaningfully.
|
|
6
|
+
|
|
7
|
+
The critical wire details baked into this client:
|
|
8
|
+
|
|
9
|
+
* the idempotency header is **``X-Idempotency``** (English) since hub 1.1.0. The hub
|
|
10
|
+
still falls back to the legacy Portuguese ``X-Idempotencia`` for old producers, but
|
|
11
|
+
new code should always send the English name.
|
|
12
|
+
* the ingest signature header is ``X-Signature`` and is only required when the hub runs
|
|
13
|
+
with ``--hmac-secret``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json as _json
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.parse
|
|
21
|
+
import urllib.request
|
|
22
|
+
from typing import Any, Dict, List, Optional
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"EventHubError",
|
|
26
|
+
"HTTPError",
|
|
27
|
+
"Response",
|
|
28
|
+
"EventHubClient",
|
|
29
|
+
"IDEMPOTENCY_HEADER",
|
|
30
|
+
"LEGACY_IDEMPOTENCY_HEADER",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
#: Inbound idempotency header the hub reads (English, since hub 1.1.0).
|
|
34
|
+
IDEMPOTENCY_HEADER = "X-Idempotency"
|
|
35
|
+
|
|
36
|
+
#: Legacy Portuguese alias, still accepted by the hub for old producers.
|
|
37
|
+
#: Do not send it from new code.
|
|
38
|
+
LEGACY_IDEMPOTENCY_HEADER = "X-Idempotencia"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EventHubError(Exception):
|
|
42
|
+
"""Base error raised by the client (transport-level)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class HTTPError(EventHubError):
|
|
46
|
+
"""Raised by :meth:`Response.raise_for_status` on a >=400 response."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, response: "Response") -> None:
|
|
49
|
+
self.response = response
|
|
50
|
+
detail = response.body if response.body is not None else response.raw
|
|
51
|
+
super().__init__("HTTP %s from event-hub: %r" % (response.status, detail))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Response:
|
|
55
|
+
"""A parsed HTTP response from the hub."""
|
|
56
|
+
|
|
57
|
+
__slots__ = ("status", "headers", "body", "raw")
|
|
58
|
+
|
|
59
|
+
def __init__(self, status: int, headers: Dict[str, str], raw: bytes) -> None:
|
|
60
|
+
self.status = status
|
|
61
|
+
self.headers = headers
|
|
62
|
+
self.raw = raw
|
|
63
|
+
text = raw.decode("utf-8", errors="replace") if raw else ""
|
|
64
|
+
if text:
|
|
65
|
+
try:
|
|
66
|
+
self.body: Any = _json.loads(text)
|
|
67
|
+
except ValueError:
|
|
68
|
+
self.body = text
|
|
69
|
+
else:
|
|
70
|
+
self.body = None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def ok(self) -> bool:
|
|
74
|
+
return 200 <= self.status < 300
|
|
75
|
+
|
|
76
|
+
def raise_for_status(self) -> "Response":
|
|
77
|
+
if not self.ok:
|
|
78
|
+
raise HTTPError(self)
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def __repr__(self) -> str:
|
|
82
|
+
return "<Response status=%s body=%r>" % (self.status, self.body)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class EventHubClient:
|
|
86
|
+
"""Client for an event-hub instance.
|
|
87
|
+
|
|
88
|
+
:param base_url: e.g. ``http://localhost:18295``.
|
|
89
|
+
:param timeout: per-request timeout in seconds.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, base_url: str = "http://localhost:18295", timeout: float = 10.0) -> None:
|
|
93
|
+
self.base_url = base_url.rstrip("/")
|
|
94
|
+
self.timeout = timeout
|
|
95
|
+
|
|
96
|
+
# -- low level ---------------------------------------------------------
|
|
97
|
+
def request(
|
|
98
|
+
self,
|
|
99
|
+
method: str,
|
|
100
|
+
path: str,
|
|
101
|
+
*,
|
|
102
|
+
body: Optional[bytes] = None,
|
|
103
|
+
headers: Optional[Dict[str, str]] = None,
|
|
104
|
+
query: Optional[Dict[str, Any]] = None,
|
|
105
|
+
) -> Response:
|
|
106
|
+
url = self.base_url + path
|
|
107
|
+
if query:
|
|
108
|
+
clean = {k: v for k, v in query.items() if v is not None}
|
|
109
|
+
if clean:
|
|
110
|
+
url += "?" + urllib.parse.urlencode(clean)
|
|
111
|
+
req = urllib.request.Request(url, data=body, method=method.upper())
|
|
112
|
+
for key, value in (headers or {}).items():
|
|
113
|
+
req.add_header(key, value)
|
|
114
|
+
try:
|
|
115
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
116
|
+
return Response(resp.status, dict(resp.headers.items()), resp.read())
|
|
117
|
+
except urllib.error.HTTPError as exc: # 4xx/5xx still carry a useful body
|
|
118
|
+
return Response(exc.code, dict(exc.headers.items()), exc.read())
|
|
119
|
+
except urllib.error.URLError as exc:
|
|
120
|
+
raise EventHubError("cannot reach event-hub at %s: %s" % (self.base_url, exc)) from exc
|
|
121
|
+
|
|
122
|
+
def _json_request(
|
|
123
|
+
self,
|
|
124
|
+
method: str,
|
|
125
|
+
path: str,
|
|
126
|
+
payload: Any = None,
|
|
127
|
+
*,
|
|
128
|
+
headers: Optional[Dict[str, str]] = None,
|
|
129
|
+
query: Optional[Dict[str, Any]] = None,
|
|
130
|
+
) -> Response:
|
|
131
|
+
body = None
|
|
132
|
+
hdrs = dict(headers or {})
|
|
133
|
+
if payload is not None:
|
|
134
|
+
body = _json.dumps(payload).encode("utf-8")
|
|
135
|
+
hdrs.setdefault("Content-Type", "application/json")
|
|
136
|
+
return self.request(method, path, body=body, headers=hdrs, query=query)
|
|
137
|
+
|
|
138
|
+
# -- health / metrics --------------------------------------------------
|
|
139
|
+
def health(self) -> Response:
|
|
140
|
+
"""``GET /health`` -> 200 ``{"ok":true,"version":...}``."""
|
|
141
|
+
return self.request("GET", "/health")
|
|
142
|
+
|
|
143
|
+
def metrics(self) -> Response:
|
|
144
|
+
"""``GET /metrics`` -> 200 counters."""
|
|
145
|
+
return self.request("GET", "/metrics")
|
|
146
|
+
|
|
147
|
+
# -- topics ------------------------------------------------------------
|
|
148
|
+
def create_topic(self, name: str, description: Optional[str] = None) -> Response:
|
|
149
|
+
"""``POST /topics`` -> 201 Topic."""
|
|
150
|
+
return self._json_request("POST", "/topics", {"name": name, "description": description})
|
|
151
|
+
|
|
152
|
+
def list_topics(self) -> Response:
|
|
153
|
+
"""``GET /topics`` -> 200 Topic[]."""
|
|
154
|
+
return self.request("GET", "/topics")
|
|
155
|
+
|
|
156
|
+
# -- subscribers -------------------------------------------------------
|
|
157
|
+
def create_subscriber(
|
|
158
|
+
self,
|
|
159
|
+
topic: str,
|
|
160
|
+
name: str,
|
|
161
|
+
*,
|
|
162
|
+
url: Optional[str] = None,
|
|
163
|
+
secret: Optional[str] = None,
|
|
164
|
+
max_attempts: Optional[int] = None,
|
|
165
|
+
backoff_ms: Optional[int] = None,
|
|
166
|
+
kind: str = "webhook",
|
|
167
|
+
command: Optional[str] = None,
|
|
168
|
+
timeout_ms: Optional[int] = None,
|
|
169
|
+
) -> Response:
|
|
170
|
+
"""``POST /topics/{topic}/subscribers`` -> 201 subscriber.
|
|
171
|
+
|
|
172
|
+
``url`` is mandatory for ``kind="webhook"``; ``command`` for ``kind="command"``.
|
|
173
|
+
"""
|
|
174
|
+
payload: Dict[str, Any] = {"name": name, "kind": kind}
|
|
175
|
+
for key, value in (
|
|
176
|
+
("url", url),
|
|
177
|
+
("secret", secret),
|
|
178
|
+
("max_attempts", max_attempts),
|
|
179
|
+
("backoff_ms", backoff_ms),
|
|
180
|
+
("command", command),
|
|
181
|
+
("timeout_ms", timeout_ms),
|
|
182
|
+
):
|
|
183
|
+
if value is not None:
|
|
184
|
+
payload[key] = value
|
|
185
|
+
return self._json_request(
|
|
186
|
+
"POST", "/topics/%s/subscribers" % urllib.parse.quote(topic, safe=""), payload
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# -- events ------------------------------------------------------------
|
|
190
|
+
def publish_event(
|
|
191
|
+
self,
|
|
192
|
+
topic: str,
|
|
193
|
+
payload: Any,
|
|
194
|
+
*,
|
|
195
|
+
idempotency_key: Optional[str] = None,
|
|
196
|
+
signature: Optional[str] = None,
|
|
197
|
+
raw: Optional[bytes] = None,
|
|
198
|
+
) -> Response:
|
|
199
|
+
"""``POST /events/{topic}`` -> 202 (new) or 200 with ``duplicado=true``.
|
|
200
|
+
|
|
201
|
+
``idempotency_key`` is sent as ``X-Idempotency`` (the header the hub reads
|
|
202
|
+
since 1.1.0; it falls back to the legacy ``X-Idempotencia``). ``signature``
|
|
203
|
+
(``sha256=<hex>``) is sent as ``X-Signature`` and is only enforced by hubs
|
|
204
|
+
started with ``--hmac-secret``. Pass ``raw`` to send exact bytes (e.g. to match
|
|
205
|
+
a pre-computed signature) instead of a JSON re-serialisation.
|
|
206
|
+
"""
|
|
207
|
+
headers: Dict[str, str] = {}
|
|
208
|
+
if idempotency_key is not None:
|
|
209
|
+
headers[IDEMPOTENCY_HEADER] = idempotency_key
|
|
210
|
+
if signature is not None:
|
|
211
|
+
headers["X-Signature"] = signature
|
|
212
|
+
if raw is not None:
|
|
213
|
+
headers.setdefault("Content-Type", "application/json")
|
|
214
|
+
body = raw
|
|
215
|
+
else:
|
|
216
|
+
body = _json.dumps(payload).encode("utf-8")
|
|
217
|
+
headers.setdefault("Content-Type", "application/json")
|
|
218
|
+
return self.request(
|
|
219
|
+
"POST", "/events/%s" % urllib.parse.quote(topic, safe=""), body=body, headers=headers
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# -- ingest ------------------------------------------------------------
|
|
223
|
+
def ingest(self, raw: bytes, *, origem: Optional[str] = None) -> Response:
|
|
224
|
+
"""``POST /ingest?origem=`` with a raw body -> 202.
|
|
225
|
+
|
|
226
|
+
404 ``{"error":"routing disabled..."}`` when the hub runs without ``--routes``.
|
|
227
|
+
"""
|
|
228
|
+
body = raw.encode("utf-8") if isinstance(raw, str) else raw
|
|
229
|
+
return self.request(
|
|
230
|
+
"POST", "/ingest", body=body, headers={"Content-Type": "application/json"},
|
|
231
|
+
query={"origem": origem},
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# -- deliveries --------------------------------------------------------
|
|
235
|
+
def list_deliveries(
|
|
236
|
+
self,
|
|
237
|
+
*,
|
|
238
|
+
status: Optional[str] = None,
|
|
239
|
+
topic: Optional[str] = None,
|
|
240
|
+
limite: Optional[int] = None,
|
|
241
|
+
) -> Response:
|
|
242
|
+
"""``GET /deliveries?status=&topic=&limite=``."""
|
|
243
|
+
return self.request(
|
|
244
|
+
"GET", "/deliveries", query={"status": status, "topic": topic, "limite": limite}
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def retry_delivery(self, delivery_id: int) -> Response:
|
|
248
|
+
"""``POST /deliveries/{id}/retry``."""
|
|
249
|
+
return self.request("POST", "/deliveries/%s/retry" % delivery_id)
|
|
250
|
+
|
|
251
|
+
def retry_failures(self) -> Response:
|
|
252
|
+
"""``POST /deliveries/retry-falhas``."""
|
|
253
|
+
return self.request("POST", "/deliveries/retry-falhas")
|
|
254
|
+
|
|
255
|
+
# -- observability -----------------------------------------------------
|
|
256
|
+
def list_proxied(self, *, source: Optional[str] = None, destination: Optional[str] = None,
|
|
257
|
+
limite: Optional[int] = None) -> Response:
|
|
258
|
+
"""``GET /proxied``."""
|
|
259
|
+
return self.request(
|
|
260
|
+
"GET", "/proxied",
|
|
261
|
+
query={"source": source, "destination": destination, "limite": limite},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def trace(self, **anchor: str) -> Response:
|
|
265
|
+
"""``GET /trace`` — exactly one anchor (tvdb/tmdb/imdb/torrent/path)."""
|
|
266
|
+
return self.request("GET", "/trace", query=anchor)
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""HMAC-SHA256 signing helpers for event-hub.
|
|
2
|
+
|
|
3
|
+
Two distinct signatures exist in event-hub:
|
|
4
|
+
|
|
5
|
+
* **Ingest signature** (``X-Signature`` on ``POST /events/{topic}``): only used when the
|
|
6
|
+
hub is started with ``--hmac-secret``. Same algorithm/format as below.
|
|
7
|
+
* **Delivery signature** (``X-Event-Hub-Signature`` on the HTTP request the hub makes to a
|
|
8
|
+
subscriber): HMAC-SHA256 of the *raw* request body with the subscriber's secret.
|
|
9
|
+
|
|
10
|
+
Both use the same wire format: ``sha256=<hex lowercase digest>``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import hmac
|
|
17
|
+
from typing import Union
|
|
18
|
+
|
|
19
|
+
__all__ = ["SIGNATURE_PREFIX", "sign", "verify_delivery_signature"]
|
|
20
|
+
|
|
21
|
+
SIGNATURE_PREFIX = "sha256="
|
|
22
|
+
|
|
23
|
+
BytesLike = Union[bytes, bytearray, str]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _as_bytes(value: BytesLike) -> bytes:
|
|
27
|
+
if isinstance(value, str):
|
|
28
|
+
return value.encode("utf-8")
|
|
29
|
+
return bytes(value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sign(secret: BytesLike, body: BytesLike) -> str:
|
|
33
|
+
"""Return ``sha256=<hex>`` = HMAC-SHA256(secret, body)."""
|
|
34
|
+
digest = hmac.new(_as_bytes(secret), _as_bytes(body), hashlib.sha256).hexdigest()
|
|
35
|
+
return SIGNATURE_PREFIX + digest
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def verify_delivery_signature(secret: BytesLike, raw_body: BytesLike, header: str) -> bool:
|
|
39
|
+
"""Constant-time verification of a delivery signature header.
|
|
40
|
+
|
|
41
|
+
``header`` is the raw value of ``X-Event-Hub-Signature`` (e.g. ``"sha256=ab12..."``).
|
|
42
|
+
``raw_body`` MUST be the exact bytes received on the wire — re-serialising JSON would
|
|
43
|
+
change whitespace/key order and break the HMAC.
|
|
44
|
+
"""
|
|
45
|
+
if not header or not isinstance(header, str):
|
|
46
|
+
return False
|
|
47
|
+
if not header.startswith(SIGNATURE_PREFIX):
|
|
48
|
+
return False
|
|
49
|
+
provided = header[len(SIGNATURE_PREFIX) :]
|
|
50
|
+
expected = hmac.new(
|
|
51
|
+
_as_bytes(secret), _as_bytes(raw_body), hashlib.sha256
|
|
52
|
+
).hexdigest()
|
|
53
|
+
return hmac.compare_digest(expected, provided)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: full-event-hub
|
|
3
|
+
Version: 1.1.4
|
|
4
|
+
Summary: Zero-dependency Python client for the event-hub webhook fan-out service
|
|
5
|
+
Author-email: Ravi Leal <ravi@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ravileal/event-hub
|
|
8
|
+
Project-URL: Repository, https://github.com/ravileal/event-hub
|
|
9
|
+
Project-URL: Issues, https://github.com/ravileal/event-hub/issues
|
|
10
|
+
Keywords: event-hub,webhooks,hmac,fan-out,events,sdk
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
|
|
28
|
+
# full-event-hub (Python client)
|
|
29
|
+
|
|
30
|
+
Zero-dependency Python client for [event-hub](https://github.com/ravileal/event-hub) — a
|
|
31
|
+
webhook fan-out service (ingest, fan-out to N subscribers, HMAC signing, retry/backoff,
|
|
32
|
+
attempts, retention).
|
|
33
|
+
|
|
34
|
+
Only the standard library is used (`urllib.request`, `json`, `hmac`, `hashlib`). The
|
|
35
|
+
package version matches the hub/image version (`ghcr.io/ravileal/event-hub`); because
|
|
36
|
+
they move together, the client sends the English `X-Idempotency` header that hubs
|
|
37
|
+
since 1.1.0 read, while a hub older than 1.1.0 ignores it.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install full-event-hub
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Publish an event
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from event_hub import EventHubClient
|
|
49
|
+
|
|
50
|
+
hub = EventHubClient("http://127.0.0.1:18295")
|
|
51
|
+
|
|
52
|
+
hub.create_topic("orders", description="order lifecycle")
|
|
53
|
+
hub.create_subscriber("orders", "billing",
|
|
54
|
+
url="http://localhost:9000/hook", secret="s3cr3t",
|
|
55
|
+
max_attempts=3, backoff_ms=200)
|
|
56
|
+
|
|
57
|
+
resp = hub.publish_event("orders", {"order_id": 1}, idempotency_key="order-1")
|
|
58
|
+
print(resp.status, resp.body) # 202 {'id': 1, 'deliveries_created': 1, 'duplicado': False}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`publish_event` sends the idempotency key as `X-Idempotency` (the header the hub
|
|
62
|
+
reads since 1.1.0; it falls back to the legacy `X-Idempotencia`). Use
|
|
63
|
+
`sign(secret, raw_body)` to sign the exact bytes you send.
|
|
64
|
+
|
|
65
|
+
## Verify an incoming delivery
|
|
66
|
+
|
|
67
|
+
event-hub signs every delivery with HMAC-SHA256 of the **raw** body using the
|
|
68
|
+
subscriber secret. Pass the exact bytes received; re-serializing JSON breaks the HMAC.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from event_hub import verify_delivery_signature
|
|
72
|
+
|
|
73
|
+
# in your webhook receiver:
|
|
74
|
+
valid = verify_delivery_signature(
|
|
75
|
+
secret, raw_body, request.headers["X-Event-Hub-Signature"]
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`verify_delivery_signature` returns `False` for a missing header, a wrong `sha256=`
|
|
80
|
+
prefix, or a non-matching digest, and compares in constant time
|
|
81
|
+
(`hmac.compare_digest`).
|
|
82
|
+
|
|
83
|
+
## Notes
|
|
84
|
+
|
|
85
|
+
- Idempotency header is **`X-Idempotency`** (English) since 1.1.0; the legacy
|
|
86
|
+
**`X-Idempotencia`** is still accepted for old producers but new code should not
|
|
87
|
+
send it.
|
|
88
|
+
- Deliveries carry `X-Event-Hub-Event`, `X-Event-Hub-Attempt` and
|
|
89
|
+
`X-Event-Hub-Signature` (`sha256=<hex>`).
|
|
90
|
+
|
|
91
|
+
MIT licensed.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/event_hub/__init__.py
|
|
4
|
+
src/event_hub/client.py
|
|
5
|
+
src/event_hub/py.typed
|
|
6
|
+
src/event_hub/signature.py
|
|
7
|
+
src/full_event_hub.egg-info/PKG-INFO
|
|
8
|
+
src/full_event_hub.egg-info/SOURCES.txt
|
|
9
|
+
src/full_event_hub.egg-info/dependency_links.txt
|
|
10
|
+
src/full_event_hub.egg-info/requires.txt
|
|
11
|
+
src/full_event_hub.egg-info/top_level.txt
|
|
12
|
+
tests/test_signature.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
event_hub
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Offline unit tests for the signature helpers (stdlib unittest, no hub).
|
|
2
|
+
|
|
3
|
+
Run from the repository root:
|
|
4
|
+
|
|
5
|
+
python -m unittest discover -s packages/python/tests -p 'test_*.py'
|
|
6
|
+
|
|
7
|
+
The three expected values are the shared cross-language contract; the same
|
|
8
|
+
strings live in tests/signature_vectors.rs (Rust reference) and
|
|
9
|
+
packages/npm/test/signature.test.mjs. If the implementations drift apart,
|
|
10
|
+
whichever one changed fails here.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import unittest
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
|
|
20
|
+
|
|
21
|
+
from event_hub import sign, verify_delivery_signature # noqa: E402
|
|
22
|
+
|
|
23
|
+
SECRET = "shared-vector-secret"
|
|
24
|
+
|
|
25
|
+
VECTORS = [
|
|
26
|
+
(
|
|
27
|
+
"simple JSON body",
|
|
28
|
+
'{"title":"hello","count":1}',
|
|
29
|
+
"sha256=1a7ee7637482254acf1a219fc564033b5824ee012bf857d4d6960fbdd7ea7f46",
|
|
30
|
+
),
|
|
31
|
+
(
|
|
32
|
+
"non-ASCII UTF-8 body",
|
|
33
|
+
"olá, 世界 — café",
|
|
34
|
+
"sha256=c1d18fed87c1c08e3c6be675ac6b1bf3c7f329ef0c0ea9f520b804a413bcd1ad",
|
|
35
|
+
),
|
|
36
|
+
(
|
|
37
|
+
"empty body",
|
|
38
|
+
"",
|
|
39
|
+
"sha256=092d0e58dcde35f9641e53be782cd12d4fdff4e6a515ec9556d66527513a6dfe",
|
|
40
|
+
),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SignatureVectorsTest(unittest.TestCase):
|
|
45
|
+
def test_sign_matches_vectors(self) -> None:
|
|
46
|
+
for name, body, expected in VECTORS:
|
|
47
|
+
with self.subTest(name=name):
|
|
48
|
+
self.assertEqual(sign(SECRET, body), expected)
|
|
49
|
+
|
|
50
|
+
def test_verify_accepts_vectors(self) -> None:
|
|
51
|
+
for name, body, expected in VECTORS:
|
|
52
|
+
with self.subTest(name=name):
|
|
53
|
+
self.assertTrue(verify_delivery_signature(SECRET, body, expected))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class VerifyNegativeTest(unittest.TestCase):
|
|
57
|
+
def setUp(self) -> None:
|
|
58
|
+
_, self.body, self.expected = VECTORS[0]
|
|
59
|
+
|
|
60
|
+
def test_rejects_tampered_body(self) -> None:
|
|
61
|
+
self.assertFalse(verify_delivery_signature(SECRET, self.body + " ", self.expected))
|
|
62
|
+
|
|
63
|
+
def test_rejects_wrong_secret(self) -> None:
|
|
64
|
+
self.assertFalse(verify_delivery_signature("another-secret", self.body, self.expected))
|
|
65
|
+
|
|
66
|
+
def test_rejects_missing_prefix(self) -> None:
|
|
67
|
+
self.assertFalse(verify_delivery_signature(SECRET, self.body, self.expected[len("sha256="):]))
|
|
68
|
+
|
|
69
|
+
def test_rejects_non_hex_digest(self) -> None:
|
|
70
|
+
self.assertFalse(verify_delivery_signature(SECRET, self.body, "sha256=zzzz"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
unittest.main()
|