hooka-relay-python 1.0.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wael Fezari
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: hooka-relay-python
3
+ Version: 1.0.0
4
+ Summary: Typed Hooka Relay event client and Standard Webhooks verification
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://hooka-relay.vercel.app/docs
7
+ Project-URL: Source, https://github.com/wauul/hooka-relay/tree/master/packages/python
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: standardwebhooks==1.1.0
12
+ Dynamic: license-file
13
+
14
+ # hooka-relay-python
15
+
16
+ Python 3.11+ client for [Hooka Relay](https://hooka-relay.vercel.app/docs#api-reference).
17
+
18
+ ```sh
19
+ pip install hooka-relay-python
20
+ ```
21
+
22
+ ```python
23
+ import os
24
+ from hooka_relay import HookaRelay
25
+ relay = HookaRelay(os.environ["HOOKA_API_KEY"])
26
+ event = relay.send_event({
27
+ "type": "order.created", "payload": {"orderId": "123"},
28
+ "idempotencyKey": "order-123-created",
29
+ })
30
+ print(event["id"])
31
+ ```
32
+
33
+ Use an ingest-only or existing unscoped application key. Optional `base_url` and `timeout` (seconds, default 30) configure the client. Remote URLs require HTTPS; redirects are rejected to avoid credential forwarding. There are no implicit retries. Retry ambiguous network failures with the **same explicit idempotencyKey**; omitted keys are generated by the server, so resending without one can create another event.
34
+
35
+ `HookaError` exposes `status`, parsed `body`, and `retry_after`. Rate limits return 429, oversized requests 413, and schema failures 400 with `failures: [{path, message}]`. Payload limits: 256 KiB / JSON depth 32. TypedDict models are generated from the repository's OpenAPI contract; runtime server validation remains authoritative.
36
+
37
+ ## Verify and queue, then drain
38
+
39
+ `verify_webhook(raw_body, headers, secret)` uses the Standard Webhooks reference library. It raises on invalid signatures, a changed ID/body, or timestamps outside five minutes. Pass raw bytes and the displayed `whsec_` secret. During rotation either old or new key verifies the dual signatures. Existing LEGACY endpoints must explicitly migrate after their receiver supports Standard Webhooks.
40
+
41
+ Minimal queue-and-drain receiver using the standard library (put a production HTTP server/reverse proxy in front of a real deployment). A bounded queue rejects overload with 503; accepted work is processed outside the request.
42
+
43
+ ```python
44
+ import os
45
+ import logging
46
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
47
+ from queue import Queue, Full
48
+ from threading import Thread
49
+ from hooka_relay import verify_webhook
50
+
51
+ queue = Queue(maxsize=1000)
52
+
53
+ def process_event(item):
54
+ print(item["id"], item["payload"])
55
+
56
+ def drain():
57
+ while True:
58
+ item = queue.get()
59
+ try:
60
+ process_event(item)
61
+ except Exception:
62
+ logging.exception("Persist failed item to your dead-letter store: %s", item["id"])
63
+ finally:
64
+ queue.task_done()
65
+
66
+ class Receiver(BaseHTTPRequestHandler):
67
+ def do_POST(self):
68
+ if self.path != "/webhook":
69
+ self.send_error(404)
70
+ return
71
+ try:
72
+ length = int(self.headers.get("Content-Length", "-1"))
73
+ except ValueError:
74
+ self.send_error(400)
75
+ return
76
+ if length < 0 or self.headers.get("Transfer-Encoding"):
77
+ self.send_error(411)
78
+ return
79
+ if length > 262144:
80
+ self.send_error(413)
81
+ return
82
+ try:
83
+ headers = {k: self.headers.get(k, "") for k in ("webhook-id", "webhook-timestamp", "webhook-signature")}
84
+ payload = verify_webhook(self.rfile.read(length), headers, os.environ["HOOKA_SIGNING_SECRET"])
85
+ except Exception:
86
+ self.send_error(400)
87
+ return
88
+ try:
89
+ queue.put_nowait({"id": headers["webhook-id"], "payload": payload})
90
+ except Full:
91
+ self.send_error(503)
92
+ return
93
+ self.send_response(202)
94
+ self.end_headers()
95
+
96
+ Thread(target=drain, daemon=True).start()
97
+ ThreadingHTTPServer(("127.0.0.1", 8080), Receiver).serve_forever()
98
+ ```
99
+
100
+ The queue is **volatile**: a crash loses already acknowledged work. For production, persist to a durable inbox/queue before acknowledging. Atomically deduplicate the authenticated `webhook-id` with business changes. Persist processing failures for retry or dead-letter handling; a log entry is not durable recovery.
101
+
102
+ ## Event ordering
103
+
104
+ Ordering is **not guaranteed across retries or replay generations**. The queue-and-drain example above separates acknowledgement from slow processing and isolates failures. It cannot restore producer order; apply per-entity sequence/version checks if required.
105
+
106
+ [Interactive API reference](https://hooka-relay.vercel.app/docs#api-reference) · [Security and migration](https://github.com/wauul/hooka-relay/blob/master/SECURITY.md)
@@ -0,0 +1,93 @@
1
+ # hooka-relay-python
2
+
3
+ Python 3.11+ client for [Hooka Relay](https://hooka-relay.vercel.app/docs#api-reference).
4
+
5
+ ```sh
6
+ pip install hooka-relay-python
7
+ ```
8
+
9
+ ```python
10
+ import os
11
+ from hooka_relay import HookaRelay
12
+ relay = HookaRelay(os.environ["HOOKA_API_KEY"])
13
+ event = relay.send_event({
14
+ "type": "order.created", "payload": {"orderId": "123"},
15
+ "idempotencyKey": "order-123-created",
16
+ })
17
+ print(event["id"])
18
+ ```
19
+
20
+ Use an ingest-only or existing unscoped application key. Optional `base_url` and `timeout` (seconds, default 30) configure the client. Remote URLs require HTTPS; redirects are rejected to avoid credential forwarding. There are no implicit retries. Retry ambiguous network failures with the **same explicit idempotencyKey**; omitted keys are generated by the server, so resending without one can create another event.
21
+
22
+ `HookaError` exposes `status`, parsed `body`, and `retry_after`. Rate limits return 429, oversized requests 413, and schema failures 400 with `failures: [{path, message}]`. Payload limits: 256 KiB / JSON depth 32. TypedDict models are generated from the repository's OpenAPI contract; runtime server validation remains authoritative.
23
+
24
+ ## Verify and queue, then drain
25
+
26
+ `verify_webhook(raw_body, headers, secret)` uses the Standard Webhooks reference library. It raises on invalid signatures, a changed ID/body, or timestamps outside five minutes. Pass raw bytes and the displayed `whsec_` secret. During rotation either old or new key verifies the dual signatures. Existing LEGACY endpoints must explicitly migrate after their receiver supports Standard Webhooks.
27
+
28
+ Minimal queue-and-drain receiver using the standard library (put a production HTTP server/reverse proxy in front of a real deployment). A bounded queue rejects overload with 503; accepted work is processed outside the request.
29
+
30
+ ```python
31
+ import os
32
+ import logging
33
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
34
+ from queue import Queue, Full
35
+ from threading import Thread
36
+ from hooka_relay import verify_webhook
37
+
38
+ queue = Queue(maxsize=1000)
39
+
40
+ def process_event(item):
41
+ print(item["id"], item["payload"])
42
+
43
+ def drain():
44
+ while True:
45
+ item = queue.get()
46
+ try:
47
+ process_event(item)
48
+ except Exception:
49
+ logging.exception("Persist failed item to your dead-letter store: %s", item["id"])
50
+ finally:
51
+ queue.task_done()
52
+
53
+ class Receiver(BaseHTTPRequestHandler):
54
+ def do_POST(self):
55
+ if self.path != "/webhook":
56
+ self.send_error(404)
57
+ return
58
+ try:
59
+ length = int(self.headers.get("Content-Length", "-1"))
60
+ except ValueError:
61
+ self.send_error(400)
62
+ return
63
+ if length < 0 or self.headers.get("Transfer-Encoding"):
64
+ self.send_error(411)
65
+ return
66
+ if length > 262144:
67
+ self.send_error(413)
68
+ return
69
+ try:
70
+ headers = {k: self.headers.get(k, "") for k in ("webhook-id", "webhook-timestamp", "webhook-signature")}
71
+ payload = verify_webhook(self.rfile.read(length), headers, os.environ["HOOKA_SIGNING_SECRET"])
72
+ except Exception:
73
+ self.send_error(400)
74
+ return
75
+ try:
76
+ queue.put_nowait({"id": headers["webhook-id"], "payload": payload})
77
+ except Full:
78
+ self.send_error(503)
79
+ return
80
+ self.send_response(202)
81
+ self.end_headers()
82
+
83
+ Thread(target=drain, daemon=True).start()
84
+ ThreadingHTTPServer(("127.0.0.1", 8080), Receiver).serve_forever()
85
+ ```
86
+
87
+ The queue is **volatile**: a crash loses already acknowledged work. For production, persist to a durable inbox/queue before acknowledging. Atomically deduplicate the authenticated `webhook-id` with business changes. Persist processing failures for retry or dead-letter handling; a log entry is not durable recovery.
88
+
89
+ ## Event ordering
90
+
91
+ Ordering is **not guaranteed across retries or replay generations**. The queue-and-drain example above separates acknowledgement from slow processing and isolates failures. It cannot restore producer order; apply per-entity sequence/version checks if required.
92
+
93
+ [Interactive API reference](https://hooka-relay.vercel.app/docs#api-reference) · [Security and migration](https://github.com/wauul/hooka-relay/blob/master/SECURITY.md)
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hooka-relay-python"
7
+ version = "1.0.0"
8
+ description = "Typed Hooka Relay event client and Standard Webhooks verification"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ dependencies = ["standardwebhooks==1.1.0"]
13
+
14
+ [project.urls]
15
+ Documentation = "https://hooka-relay.vercel.app/docs"
16
+ Source = "https://github.com/wauul/hooka-relay/tree/master/packages/python"
17
+
18
+ [tool.setuptools.package-data]
19
+ hooka_relay = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,67 @@
1
+ """Server-side event client. API keys must never be shipped to a browser."""
2
+ import json
3
+ import math
4
+ from urllib.error import HTTPError
5
+ from urllib.parse import urlparse
6
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
7
+ from standardwebhooks.webhooks import Webhook
8
+ from .generated import EventInput, EventResponse, ErrorResponse, JsonValue
9
+
10
+ __all__ = ["HookaRelay", "HookaError", "verify_webhook", "EventInput", "EventResponse", "ErrorResponse", "JsonValue"]
11
+
12
+
13
+ class HookaError(Exception):
14
+ def __init__(self, status: int, body: object, retry_after: str | None):
15
+ super().__init__(f"Hooka Relay returned HTTP {status}")
16
+ self.status = status
17
+ self.body = body
18
+ self.retry_after = retry_after
19
+
20
+
21
+ class _NoRedirect(HTTPRedirectHandler):
22
+ # Never forward a publishing credential to a redirected origin.
23
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
24
+ return None
25
+
26
+
27
+ class HookaRelay:
28
+ def __init__(self, api_key: str, *, base_url: str = "https://hooka-relay.vercel.app", timeout: float = 30):
29
+ if not api_key or "\r" in api_key or "\n" in api_key:
30
+ raise ValueError("An API key is required")
31
+ url = urlparse(base_url)
32
+ if url.scheme not in ("https", "http") or not url.hostname or url.username or url.password:
33
+ raise ValueError("Invalid base URL")
34
+ if url.scheme == "http" and url.hostname not in ("localhost", "127.0.0.1", "::1"):
35
+ raise ValueError("Use HTTPS outside localhost")
36
+ if not math.isfinite(timeout) or timeout <= 0:
37
+ raise ValueError("Invalid timeout")
38
+ self._url = f"{url.scheme}://{url.netloc}/api/v1/events"
39
+ self._api_key = api_key
40
+ self._timeout = timeout
41
+ self._opener = build_opener(_NoRedirect())
42
+
43
+ def send_event(self, event: EventInput) -> EventResponse:
44
+ """Send once. For retries, reuse an explicit idempotencyKey."""
45
+ request = Request(self._url, data=json.dumps(event, allow_nan=False).encode(), method="POST", headers={
46
+ "Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json",
47
+ })
48
+ try:
49
+ response = self._opener.open(request, timeout=self._timeout)
50
+ except HTTPError as error:
51
+ response = error
52
+ with response:
53
+ raw = response.read().decode("utf-8", errors="replace")
54
+ try:
55
+ body = json.loads(raw)
56
+ except ValueError:
57
+ body = raw
58
+ if response.status != 202:
59
+ raise HookaError(response.status, body, response.headers.get("Retry-After"))
60
+ if not isinstance(body, dict) or not isinstance(body.get("id"), str):
61
+ raise ValueError("Invalid Hooka Relay event response")
62
+ return body
63
+
64
+
65
+ def verify_webhook(raw_body: str | bytes, headers: dict[str, str], secret: str) -> object:
66
+ """Verify exact raw bytes, signed event ID and the reference library's clock window."""
67
+ return Webhook(secret).verify(raw_body, headers)
@@ -0,0 +1,31 @@
1
+ # Generated from docs/openapi.json; run npm run sdk:generate.
2
+ from typing import NotRequired, TypedDict, Union
3
+
4
+ JsonValue = Union[None, bool, int, float, str, list["JsonValue"], dict[str, "JsonValue"]]
5
+
6
+
7
+ class EventInput(TypedDict):
8
+ type: str
9
+ payload: JsonValue
10
+ idempotencyKey: NotRequired[str]
11
+
12
+
13
+ class EventResponse(TypedDict):
14
+ id: str
15
+ applicationId: str
16
+ type: str
17
+ payload: JsonValue
18
+ idempotencyKey: str
19
+ operational: bool
20
+ createdAt: str
21
+
22
+
23
+ class SchemaFailure(TypedDict):
24
+ path: str
25
+ message: str
26
+
27
+
28
+ class ErrorResponse(TypedDict):
29
+ error: str
30
+ retryAfter: NotRequired[int]
31
+ failures: NotRequired[list[SchemaFailure]]
File without changes
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: hooka-relay-python
3
+ Version: 1.0.0
4
+ Summary: Typed Hooka Relay event client and Standard Webhooks verification
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://hooka-relay.vercel.app/docs
7
+ Project-URL: Source, https://github.com/wauul/hooka-relay/tree/master/packages/python
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: standardwebhooks==1.1.0
12
+ Dynamic: license-file
13
+
14
+ # hooka-relay-python
15
+
16
+ Python 3.11+ client for [Hooka Relay](https://hooka-relay.vercel.app/docs#api-reference).
17
+
18
+ ```sh
19
+ pip install hooka-relay-python
20
+ ```
21
+
22
+ ```python
23
+ import os
24
+ from hooka_relay import HookaRelay
25
+ relay = HookaRelay(os.environ["HOOKA_API_KEY"])
26
+ event = relay.send_event({
27
+ "type": "order.created", "payload": {"orderId": "123"},
28
+ "idempotencyKey": "order-123-created",
29
+ })
30
+ print(event["id"])
31
+ ```
32
+
33
+ Use an ingest-only or existing unscoped application key. Optional `base_url` and `timeout` (seconds, default 30) configure the client. Remote URLs require HTTPS; redirects are rejected to avoid credential forwarding. There are no implicit retries. Retry ambiguous network failures with the **same explicit idempotencyKey**; omitted keys are generated by the server, so resending without one can create another event.
34
+
35
+ `HookaError` exposes `status`, parsed `body`, and `retry_after`. Rate limits return 429, oversized requests 413, and schema failures 400 with `failures: [{path, message}]`. Payload limits: 256 KiB / JSON depth 32. TypedDict models are generated from the repository's OpenAPI contract; runtime server validation remains authoritative.
36
+
37
+ ## Verify and queue, then drain
38
+
39
+ `verify_webhook(raw_body, headers, secret)` uses the Standard Webhooks reference library. It raises on invalid signatures, a changed ID/body, or timestamps outside five minutes. Pass raw bytes and the displayed `whsec_` secret. During rotation either old or new key verifies the dual signatures. Existing LEGACY endpoints must explicitly migrate after their receiver supports Standard Webhooks.
40
+
41
+ Minimal queue-and-drain receiver using the standard library (put a production HTTP server/reverse proxy in front of a real deployment). A bounded queue rejects overload with 503; accepted work is processed outside the request.
42
+
43
+ ```python
44
+ import os
45
+ import logging
46
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
47
+ from queue import Queue, Full
48
+ from threading import Thread
49
+ from hooka_relay import verify_webhook
50
+
51
+ queue = Queue(maxsize=1000)
52
+
53
+ def process_event(item):
54
+ print(item["id"], item["payload"])
55
+
56
+ def drain():
57
+ while True:
58
+ item = queue.get()
59
+ try:
60
+ process_event(item)
61
+ except Exception:
62
+ logging.exception("Persist failed item to your dead-letter store: %s", item["id"])
63
+ finally:
64
+ queue.task_done()
65
+
66
+ class Receiver(BaseHTTPRequestHandler):
67
+ def do_POST(self):
68
+ if self.path != "/webhook":
69
+ self.send_error(404)
70
+ return
71
+ try:
72
+ length = int(self.headers.get("Content-Length", "-1"))
73
+ except ValueError:
74
+ self.send_error(400)
75
+ return
76
+ if length < 0 or self.headers.get("Transfer-Encoding"):
77
+ self.send_error(411)
78
+ return
79
+ if length > 262144:
80
+ self.send_error(413)
81
+ return
82
+ try:
83
+ headers = {k: self.headers.get(k, "") for k in ("webhook-id", "webhook-timestamp", "webhook-signature")}
84
+ payload = verify_webhook(self.rfile.read(length), headers, os.environ["HOOKA_SIGNING_SECRET"])
85
+ except Exception:
86
+ self.send_error(400)
87
+ return
88
+ try:
89
+ queue.put_nowait({"id": headers["webhook-id"], "payload": payload})
90
+ except Full:
91
+ self.send_error(503)
92
+ return
93
+ self.send_response(202)
94
+ self.end_headers()
95
+
96
+ Thread(target=drain, daemon=True).start()
97
+ ThreadingHTTPServer(("127.0.0.1", 8080), Receiver).serve_forever()
98
+ ```
99
+
100
+ The queue is **volatile**: a crash loses already acknowledged work. For production, persist to a durable inbox/queue before acknowledging. Atomically deduplicate the authenticated `webhook-id` with business changes. Persist processing failures for retry or dead-letter handling; a log entry is not durable recovery.
101
+
102
+ ## Event ordering
103
+
104
+ Ordering is **not guaranteed across retries or replay generations**. The queue-and-drain example above separates acknowledgement from slow processing and isolates failures. It cannot restore producer order; apply per-entity sequence/version checks if required.
105
+
106
+ [Interactive API reference](https://hooka-relay.vercel.app/docs#api-reference) · [Security and migration](https://github.com/wauul/hooka-relay/blob/master/SECURITY.md)
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/hooka_relay/__init__.py
5
+ src/hooka_relay/generated.py
6
+ src/hooka_relay/py.typed
7
+ src/hooka_relay_python.egg-info/PKG-INFO
8
+ src/hooka_relay_python.egg-info/SOURCES.txt
9
+ src/hooka_relay_python.egg-info/dependency_links.txt
10
+ src/hooka_relay_python.egg-info/requires.txt
11
+ src/hooka_relay_python.egg-info/top_level.txt
12
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ standardwebhooks==1.1.0
@@ -0,0 +1,69 @@
1
+ import base64
2
+ import io
3
+ import json
4
+ import unittest
5
+ from datetime import datetime, timedelta, timezone
6
+ from unittest.mock import Mock
7
+ from urllib.error import HTTPError
8
+ from hooka_relay import HookaRelay, HookaError, verify_webhook
9
+ from standardwebhooks.webhooks import Webhook
10
+
11
+
12
+ class Response(io.BytesIO):
13
+ def __init__(self, body, status=202):
14
+ super().__init__(json.dumps(body).encode())
15
+ self.status = status
16
+ self.headers = {"Retry-After": "12"}
17
+
18
+
19
+ class ClientTests(unittest.TestCase):
20
+ def test_send(self):
21
+ client = HookaRelay("test-key")
22
+ event = {"id": "evt_1", "type": "test", "payload": None}
23
+ client._opener = Mock()
24
+ client._opener.open.return_value = Response(event)
25
+ request = {"type": "test", "payload": None, "idempotencyKey": "stable"}
26
+ self.assertEqual(client.send_event(request), event)
27
+ sent = client._opener.open.call_args.args[0]
28
+ self.assertEqual(sent.full_url, "https://hooka-relay.vercel.app/api/v1/events")
29
+ self.assertEqual(json.loads(sent.data), request)
30
+ self.assertEqual(sent.get_header("Authorization"), "Bearer test-key")
31
+ client._opener.open.assert_called_once()
32
+
33
+ def test_errors_no_retries(self):
34
+ for status in (400, 401, 403, 413, 429, 503):
35
+ client = HookaRelay("secret-key")
36
+ client._opener = Mock()
37
+ client._opener.open.side_effect = HTTPError(client._url, status, "test", {"Retry-After": "12"}, io.BytesIO(b'{"error":"test"}'))
38
+ with self.assertRaises(HookaError) as caught:
39
+ client.send_event({"type": "test", "payload": None})
40
+ self.assertEqual(caught.exception.status, status)
41
+ self.assertEqual(caught.exception.retry_after, "12")
42
+ self.assertNotIn("secret-key", str(caught.exception))
43
+ client._opener.open.assert_called_once()
44
+
45
+ def test_invalid_urls_and_redirect(self):
46
+ for url in ("http://example.com", "https://user:pass@example.com", "file:///test"):
47
+ with self.assertRaises(ValueError):
48
+ HookaRelay("key", base_url=url)
49
+ client = HookaRelay("key")
50
+ redirect = next(h for h in client._opener.handlers if hasattr(h, "redirect_request"))
51
+ self.assertIsNone(redirect.redirect_request(None, None, 302, "", {}, "https://other.example"))
52
+
53
+ def test_reference_verification(self):
54
+ keys = ["whsec_" + base64.b64encode(bytes([n]) * 32).decode() for n in (1, 2)]
55
+ now = datetime.now(timezone.utc)
56
+ raw, event_id = '{"hello":"world"}', "evt_stable"
57
+ headers = {"webhook-id": event_id, "webhook-timestamp": str(int(now.timestamp())), "webhook-signature": " ".join(Webhook(k).sign(event_id, now, raw) for k in keys)}
58
+ for key in keys:
59
+ self.assertEqual(verify_webhook(raw, headers, key), {"hello": "world"})
60
+ for altered, body in (({**headers, "webhook-id": "forged"}, raw), (headers, raw + " ")):
61
+ with self.assertRaises(Exception):
62
+ verify_webhook(body, altered, keys[0])
63
+ old = now - timedelta(minutes=10)
64
+ with self.assertRaises(Exception):
65
+ verify_webhook(raw, {**headers, "webhook-timestamp": str(int(old.timestamp())), "webhook-signature": Webhook(keys[0]).sign(event_id, old, raw)}, keys[0])
66
+
67
+
68
+ if __name__ == "__main__":
69
+ unittest.main()