xtr-webhook 4.0.2__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.
- xtr_webhook/.agents/skills/xtr-webhook/SKILL.md +350 -0
- xtr_webhook/.agents/skills/xtr-webhook/references/signature-formats.md +189 -0
- xtr_webhook/__init__.py +10 -0
- xtr_webhook/_endpoint_paths.py +22 -0
- xtr_webhook/_standard_webhooks_signature.py +76 -0
- xtr_webhook/bundle/__init__.py +15 -0
- xtr_webhook/bundle/_routed_parsers.py +70 -0
- xtr_webhook/bundle/no_private_network_config.py +59 -0
- xtr_webhook/bundle/route_config.py +34 -0
- xtr_webhook/bundle/webhook_bundle.py +262 -0
- xtr_webhook/bundle/webhook_config.py +80 -0
- xtr_webhook/client/__init__.py +23 -0
- xtr_webhook/client/abstract_request_parser.py +248 -0
- xtr_webhook/client/request_parser.py +272 -0
- xtr_webhook/client/request_parser_interface.py +90 -0
- xtr_webhook/controller/__init__.py +12 -0
- xtr_webhook/controller/_built_parsers.py +65 -0
- xtr_webhook/controller/webhook_controller.py +140 -0
- xtr_webhook/exception/__init__.py +25 -0
- xtr_webhook/exception/delivery_error.py +38 -0
- xtr_webhook/exception/invalid_argument_error.py +18 -0
- xtr_webhook/exception/logic_error.py +15 -0
- xtr_webhook/exception/reject_webhook_error.py +52 -0
- xtr_webhook/exception/webhook_error.py +9 -0
- xtr_webhook/exception/webhook_runtime_error.py +16 -0
- xtr_webhook/messenger/__init__.py +14 -0
- xtr_webhook/messenger/send_webhook_handler.py +49 -0
- xtr_webhook/messenger/send_webhook_message.py +36 -0
- xtr_webhook/py.typed +0 -0
- xtr_webhook/recipe/__init__.py +18 -0
- xtr_webhook/recipe/files/config/routes/webhook.py.tmpl +25 -0
- xtr_webhook/recipe/files/config/webhook.py.tmpl +33 -0
- xtr_webhook/recipe/manifest.toml +35 -0
- xtr_webhook/routing/__init__.py +11 -0
- xtr_webhook/routing/_endpoints.py +38 -0
- xtr_webhook/routing/webhook_route_loader.py +31 -0
- xtr_webhook/server/__init__.py +39 -0
- xtr_webhook/server/header_signature_configurator.py +128 -0
- xtr_webhook/server/headers_configurator.py +92 -0
- xtr_webhook/server/json_body_configurator.py +80 -0
- xtr_webhook/server/json_payload_serializer.py +45 -0
- xtr_webhook/server/outgoing_request.py +35 -0
- xtr_webhook/server/payload_serializer_interface.py +34 -0
- xtr_webhook/server/request_configurator_interface.py +39 -0
- xtr_webhook/server/signature_format.py +65 -0
- xtr_webhook/server/transport.py +106 -0
- xtr_webhook/server/transport_interface.py +40 -0
- xtr_webhook/subscriber.py +41 -0
- xtr_webhook/testing/__init__.py +18 -0
- xtr_webhook/testing/_parser_cases.py +141 -0
- xtr_webhook/testing/parser_case.py +37 -0
- xtr_webhook-4.0.2.dist-info/METADATA +704 -0
- xtr_webhook-4.0.2.dist-info/RECORD +56 -0
- xtr_webhook-4.0.2.dist-info/WHEEL +4 -0
- xtr_webhook-4.0.2.dist-info/entry_points.txt +6 -0
- xtr_webhook-4.0.2.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""What answers a webhook: read it, hand on what it carried, reply to its sender."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import TYPE_CHECKING, Final, TypeVar, final
|
|
7
|
+
|
|
8
|
+
from xtr_http_message_contracts import ResponseInterface
|
|
9
|
+
from xtr_remote_event import RemoteEvent
|
|
10
|
+
from xtr_remote_event.messenger import ConsumeRemoteEventMessage
|
|
11
|
+
|
|
12
|
+
from xtr_webhook.exception import RejectWebhookError
|
|
13
|
+
|
|
14
|
+
from ._built_parsers import BuiltParsers
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from xtr_http_message_contracts import ServerRequestInterface
|
|
18
|
+
from xtr_messenger import MessageBusInterface
|
|
19
|
+
from xtr_service_contracts import ServiceProviderInterface
|
|
20
|
+
|
|
21
|
+
from xtr_webhook.client import RequestParserInterface
|
|
22
|
+
|
|
23
|
+
__all__ = ["WebhookController"]
|
|
24
|
+
|
|
25
|
+
R = TypeVar("R", bound=ResponseInterface)
|
|
26
|
+
|
|
27
|
+
_NOT_FOUND: Final = 404
|
|
28
|
+
_PLAIN_TEXT: Final = "text/plain; charset=utf-8"
|
|
29
|
+
_NO_PARSER: Final = "No webhook parser found for the type given in the URL."
|
|
30
|
+
_UNPARSABLE: Final = "Unable to parse the webhook payload."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _as_plain_text(
|
|
34
|
+
response: R,
|
|
35
|
+
status_code: int,
|
|
36
|
+
reason: str,
|
|
37
|
+
headers: Mapping[str, str],
|
|
38
|
+
) -> R:
|
|
39
|
+
"""Return ``response`` carrying ``reason`` as text under ``status_code``.
|
|
40
|
+
|
|
41
|
+
``headers`` is applied last, so a refusal that needs a header of its own —
|
|
42
|
+
a challenge under a 401 — sets it, and may override the content type if
|
|
43
|
+
its own answer is not text.
|
|
44
|
+
"""
|
|
45
|
+
answered = response.with_status(status_code).with_header("Content-Type", _PLAIN_TEXT)
|
|
46
|
+
for name, value in headers.items():
|
|
47
|
+
answered = answered.with_header(name, value)
|
|
48
|
+
_ = await answered.get_body().write(reason.encode())
|
|
49
|
+
|
|
50
|
+
return answered
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@final
|
|
54
|
+
class WebhookController:
|
|
55
|
+
"""Receives every incoming webhook, whoever sent it.
|
|
56
|
+
|
|
57
|
+
One endpoint for every subscriber, told apart by the type in the URL: that
|
|
58
|
+
type picks the parser that reads the webhook and the secret it is checked
|
|
59
|
+
against, so adding a subscriber is configuration rather than another route.
|
|
60
|
+
|
|
61
|
+
What the endpoint itself does is deliberately small. Reading a webhook is
|
|
62
|
+
the parser's, handling what it carried is the consumer's, and in between
|
|
63
|
+
this does the one thing neither can: answer the sender now, and leave the
|
|
64
|
+
work for a worker. A sender waiting on a handler is a sender that times
|
|
65
|
+
out and sends the webhook again.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
__slots__ = ("_bus", "_parsers", "_secrets")
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
parsers: ServiceProviderInterface[RequestParserInterface]
|
|
73
|
+
| Mapping[str, RequestParserInterface],
|
|
74
|
+
secrets: Mapping[str, str],
|
|
75
|
+
bus: MessageBusInterface,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Receive webhooks for the types ``parsers`` speaks for.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
parsers: The parser reading each type, by type. A container's
|
|
81
|
+
locator builds each on the first webhook for it; a mapping of
|
|
82
|
+
parsers built by hand is wrapped in one that behaves the same.
|
|
83
|
+
secrets: What each type's webhooks are checked against, by type.
|
|
84
|
+
Copied, so what a type is checked against cannot change under
|
|
85
|
+
a request. A type with no entry is checked against an empty
|
|
86
|
+
secret, which a parser verifying anything refuses.
|
|
87
|
+
bus: Where an event that was read is handed on.
|
|
88
|
+
"""
|
|
89
|
+
self._parsers: ServiceProviderInterface[RequestParserInterface] = (
|
|
90
|
+
BuiltParsers(parsers) if isinstance(parsers, Mapping) else parsers
|
|
91
|
+
)
|
|
92
|
+
self._secrets = dict(secrets)
|
|
93
|
+
self._bus = bus
|
|
94
|
+
|
|
95
|
+
async def index_action(
|
|
96
|
+
self,
|
|
97
|
+
event_type: str,
|
|
98
|
+
request: ServerRequestInterface,
|
|
99
|
+
response: R,
|
|
100
|
+
) -> R:
|
|
101
|
+
"""Read the webhook that arrived for ``event_type``, and answer its sender.
|
|
102
|
+
|
|
103
|
+
Every answer but the last is a refusal, and each says as little as the
|
|
104
|
+
sender needs: a type nothing speaks for is a 404, because from the
|
|
105
|
+
sender's side that URL does not exist; a webhook the parser would not
|
|
106
|
+
take is whatever the parser answers; a webhook it read nothing out of
|
|
107
|
+
is refused as unparsable rather than accepted and dropped.
|
|
108
|
+
|
|
109
|
+
Anything else raised on the way out — a bus with nothing to handle the
|
|
110
|
+
event above all — is left to propagate. The sender then gets a 5xx and
|
|
111
|
+
sends the webhook again, which is the only outcome that does not lose
|
|
112
|
+
what it carried.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
event_type: The type in the URL: which subscriber this is for.
|
|
116
|
+
request: The webhook as it arrived.
|
|
117
|
+
response: The response to answer with, to fill in and give back.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
``response`` with the answer on it.
|
|
121
|
+
"""
|
|
122
|
+
if not self._parsers.has(event_type):
|
|
123
|
+
return await _as_plain_text(response, _NOT_FOUND, _NO_PARSER, {})
|
|
124
|
+
|
|
125
|
+
parser = await self._parsers.get(event_type)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
events = await parser.parse(request, self._secrets.get(event_type, ""))
|
|
129
|
+
except RejectWebhookError as refused:
|
|
130
|
+
return await _as_plain_text(
|
|
131
|
+
response, refused.status_code, str(refused), refused.headers
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if not events:
|
|
135
|
+
return await parser.create_rejected_response(_UNPARSABLE, request, response)
|
|
136
|
+
|
|
137
|
+
for event in [events] if isinstance(events, RemoteEvent) else events:
|
|
138
|
+
_ = await self._bus.dispatch(ConsumeRemoteEventMessage(event_type, event))
|
|
139
|
+
|
|
140
|
+
return await parser.create_successful_response(request, response)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Every error this library raises.
|
|
2
|
+
|
|
3
|
+
All of them derive from :class:`WebhookError`, so one ``except`` catches anything a
|
|
4
|
+
webhook can go wrong with, and a narrower one handles a single cause. Each carries what
|
|
5
|
+
went wrong as typed attributes rather than forcing a message to be parsed — and none of
|
|
6
|
+
them repeats a secret.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .delivery_error import DeliveryError
|
|
12
|
+
from .invalid_argument_error import InvalidArgumentError
|
|
13
|
+
from .logic_error import LogicError
|
|
14
|
+
from .reject_webhook_error import RejectWebhookError
|
|
15
|
+
from .webhook_error import WebhookError
|
|
16
|
+
from .webhook_runtime_error import WebhookRuntimeError
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"DeliveryError",
|
|
20
|
+
"InvalidArgumentError",
|
|
21
|
+
"LogicError",
|
|
22
|
+
"RejectWebhookError",
|
|
23
|
+
"WebhookError",
|
|
24
|
+
"WebhookRuntimeError",
|
|
25
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""An outgoing webhook was not accepted by the receiver."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .webhook_runtime_error import WebhookRuntimeError
|
|
6
|
+
|
|
7
|
+
__all__ = ["DeliveryError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DeliveryError(WebhookRuntimeError):
|
|
11
|
+
"""A receiver answered an outgoing webhook with a status that is not a success.
|
|
12
|
+
|
|
13
|
+
Raised rather than returned, because a delivery runs inside a message handler:
|
|
14
|
+
an error is what makes the bus retry it and, once the attempts are spent, keep
|
|
15
|
+
the message instead of dropping it. A receiver that was merely restarting then
|
|
16
|
+
costs nothing, and one that stays broken leaves a message to look at.
|
|
17
|
+
|
|
18
|
+
A delivery nothing answered at all is the client's own ``TransportError``, raised
|
|
19
|
+
before this one could be, so the status here is always one a receiver sent.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
status_code: What the receiver answered with.
|
|
23
|
+
url: Where the webhook was going.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
status_code: int
|
|
27
|
+
url: str
|
|
28
|
+
|
|
29
|
+
def __init__(self, status_code: int, url: str) -> None:
|
|
30
|
+
"""Record where the webhook was going and what came back.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
status_code: What the receiver answered with.
|
|
34
|
+
url: Where the webhook was going.
|
|
35
|
+
"""
|
|
36
|
+
self.status_code = status_code
|
|
37
|
+
self.url = url
|
|
38
|
+
super().__init__(f'The webhook to "{url}" was answered with HTTP {status_code}.')
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""A value handed to this library cannot be used."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .webhook_error import WebhookError
|
|
6
|
+
|
|
7
|
+
__all__ = ["InvalidArgumentError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidArgumentError(WebhookError, ValueError):
|
|
11
|
+
"""A value handed to this library cannot be used: a signature format, an empty secret.
|
|
12
|
+
|
|
13
|
+
Also a :class:`ValueError`, so code that already guards against a bad value
|
|
14
|
+
catches it without knowing this library exists. It is never an answer to a
|
|
15
|
+
request: a webhook refused because of what it carries is a
|
|
16
|
+
:class:`RejectWebhookError <xtr_webhook.exception.RejectWebhookError>`, while
|
|
17
|
+
this is a mistake in the configuration to be fixed.
|
|
18
|
+
"""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""This library was used in a way that cannot work."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .webhook_error import WebhookError
|
|
6
|
+
|
|
7
|
+
__all__ = ["LogicError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LogicError(WebhookError, RuntimeError):
|
|
11
|
+
"""This library was used in a way that cannot work, whatever the request carries.
|
|
12
|
+
|
|
13
|
+
A parser asked for a header its format never sends, a signer without a secret
|
|
14
|
+
to sign with: each is a mistake in the code, so retrying it changes nothing.
|
|
15
|
+
"""
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""An incoming webhook is refused, and the answer to send back says so."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .webhook_error import WebhookError
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
__all__ = ["RejectWebhookError"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RejectWebhookError(WebhookError):
|
|
16
|
+
"""An incoming webhook is refused: it carries the answer the sender is given.
|
|
17
|
+
|
|
18
|
+
Everything here reaches the sender — the endpoint answers with the status,
|
|
19
|
+
the headers, and the message as a plain-text body. A sender is not debugged
|
|
20
|
+
by the receiver, so the message says which check refused it and nothing
|
|
21
|
+
more: never the values that failed it, nothing an attacker could probe with.
|
|
22
|
+
The default, 406, says the request was understood and still not acceptable,
|
|
23
|
+
which is what a wrong signature, a stale timestamp or an unreadable payload
|
|
24
|
+
all amount to from the sender's side.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
status_code: What to answer with.
|
|
28
|
+
headers: Headers the answer must carry, empty unless the status needs any.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
status_code: int
|
|
32
|
+
headers: Mapping[str, str]
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
status_code: int = 406,
|
|
37
|
+
message: str = "",
|
|
38
|
+
headers: Mapping[str, str] | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Refuse the webhook, and record what to answer.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
status_code: What to answer with.
|
|
44
|
+
message: Why it was refused. The endpoint sends it to the sender as
|
|
45
|
+
the answer's plain-text body, so name the check that refused the
|
|
46
|
+
webhook, never the values that failed it.
|
|
47
|
+
headers: Headers the answer must carry.
|
|
48
|
+
"""
|
|
49
|
+
self.status_code = status_code
|
|
50
|
+
# Copied so the caller's mapping cannot change the answer after the refusal.
|
|
51
|
+
self.headers = {} if headers is None else dict(headers)
|
|
52
|
+
super().__init__(message)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Something a webhook depends on failed while it was being handled."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .webhook_error import WebhookError
|
|
6
|
+
|
|
7
|
+
__all__ = ["WebhookRuntimeError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WebhookRuntimeError(WebhookError, RuntimeError):
|
|
11
|
+
"""Something a webhook depends on failed while it was handled — a receiver that refuses.
|
|
12
|
+
|
|
13
|
+
The input was fine and the code is fine; the world did not cooperate. Raising
|
|
14
|
+
one out of a message handler is what makes a message bus retry the delivery and,
|
|
15
|
+
once it has run out of attempts, keep the message rather than drop it.
|
|
16
|
+
"""
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Sending a webhook on a message bus: the message, and what handles it.
|
|
2
|
+
|
|
3
|
+
Delivering through the bus rather than inline is what gives sending everything the
|
|
4
|
+
bus already has — retries with a delay, a dead-letter place for what stays refused,
|
|
5
|
+
and a worker pool to do the waiting — without this library growing a retry policy of
|
|
6
|
+
its own.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .send_webhook_handler import SendWebhookHandler
|
|
12
|
+
from .send_webhook_message import SendWebhookMessage
|
|
13
|
+
|
|
14
|
+
__all__ = ["SendWebhookHandler", "SendWebhookMessage"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Delivers the webhook a :class:`SendWebhookMessage` asks for."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from xtr_messenger import as_message_handler
|
|
8
|
+
|
|
9
|
+
from xtr_webhook.server.transport_interface import TransportInterface
|
|
10
|
+
|
|
11
|
+
from .send_webhook_message import SendWebhookMessage
|
|
12
|
+
|
|
13
|
+
__all__ = ["SendWebhookHandler"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@as_message_handler(SendWebhookMessage)
|
|
17
|
+
@final
|
|
18
|
+
class SendWebhookHandler:
|
|
19
|
+
"""Hands the message's subscriber and event to the transport, and nothing else.
|
|
20
|
+
|
|
21
|
+
It is handed a :class:`~xtr_webhook.server.TransportInterface`, never a
|
|
22
|
+
container: delivering is the only question it has, so a handler given the
|
|
23
|
+
container could reach everything else the application registered as well.
|
|
24
|
+
|
|
25
|
+
Queueing the delivery is what keeps a slow receiver out of the request that
|
|
26
|
+
caused the event. The price is that the subscriber secret travels inside the
|
|
27
|
+
queued message body, so the queue storage must be trusted. The bus itself adds
|
|
28
|
+
nothing to that: its logging middleware records the message type, the
|
|
29
|
+
transport, the message id, the handler name and the stamps — never the body.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
__slots__ = ("_transport",)
|
|
33
|
+
|
|
34
|
+
def __init__(self, transport: TransportInterface) -> None:
|
|
35
|
+
"""Deliver through ``transport``."""
|
|
36
|
+
self._transport = transport
|
|
37
|
+
|
|
38
|
+
async def __call__(self, message: SendWebhookMessage) -> None:
|
|
39
|
+
"""Deliver ``message.event`` to ``message.subscriber``.
|
|
40
|
+
|
|
41
|
+
Nothing is caught. A refusal is what makes the bus try the delivery
|
|
42
|
+
again and, once the attempts are spent, keep the message instead of
|
|
43
|
+
dropping it; swallowing it here would turn every refused webhook into
|
|
44
|
+
one silently never sent.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
DeliveryError: When the receiver did not accept the webhook.
|
|
48
|
+
"""
|
|
49
|
+
await self._transport.send(message.subscriber, message.event)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Asks for one event to be signed and delivered to one subscriber."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import final
|
|
7
|
+
|
|
8
|
+
from xtr_messenger import as_message
|
|
9
|
+
from xtr_remote_event import RemoteEvent
|
|
10
|
+
|
|
11
|
+
from xtr_webhook.subscriber import Subscriber
|
|
12
|
+
|
|
13
|
+
__all__ = ["SendWebhookMessage"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@as_message(name="xtr_webhook.send.v1")
|
|
17
|
+
@final
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class SendWebhookMessage:
|
|
20
|
+
"""Deliver ``event`` to ``subscriber``.
|
|
21
|
+
|
|
22
|
+
What turns deciding to send a webhook into sending one, and the reason the
|
|
23
|
+
two happen in different processes: whatever decided returns at once, and the
|
|
24
|
+
receiver — which may be slow, restarting or gone — is somebody else's wait.
|
|
25
|
+
|
|
26
|
+
It carries the subscriber whole rather than an id to look up, because this
|
|
27
|
+
library stores no subscribers; the application knows where a webhook goes
|
|
28
|
+
and what signs it, and says so once, here.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
subscriber: Where the webhook goes, and the secret it is signed with.
|
|
32
|
+
event: What to deliver, exactly as the sender described it.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
subscriber: Subscriber
|
|
36
|
+
event: RemoteEvent
|
xtr_webhook/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""This directory holds the package's recipe: its manifest and its template.
|
|
2
|
+
|
|
3
|
+
The recipe is what *Use in an application* in the README lists, written as
|
|
4
|
+
data: the bundle to list, the configuration module to write, and the
|
|
5
|
+
environment variable the written module reads. An application applies it with
|
|
6
|
+
``xtr-recipes recipes:sync``, which finds this module through the
|
|
7
|
+
``xtr_recipes`` entry point.
|
|
8
|
+
|
|
9
|
+
The endpoint needs no step of its own: ``setup`` adds it, and the routes file
|
|
10
|
+
this recipe writes says where. Naming each sender's type is left to a person.
|
|
11
|
+
|
|
12
|
+
Nothing is importable from here. The manifest and the template are read as
|
|
13
|
+
files, so a recipe is the same from a wheel as from a working tree.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Where this application receives webhooks: ``POST /webhook/{type}`` by default.
|
|
2
|
+
|
|
3
|
+
Give each sender the URL built from the route's name, ``"webhook"``, rather than
|
|
4
|
+
writing the path out: a sender that was told a URL keeps sending to it long
|
|
5
|
+
after the deploy that moved it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from xtr_http_kernel.routing.decorator import as_routes
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from xtr_http_kernel.routing.loader.configurator import RoutingConfigurator
|
|
16
|
+
|
|
17
|
+
__all__ = ["webhook"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@as_routes
|
|
21
|
+
def webhook(routes: RoutingConfigurator) -> None:
|
|
22
|
+
"""Serve the endpoint at its default path."""
|
|
23
|
+
_ = routes.import_("webhook")
|
|
24
|
+
# Keep everything a stranger may call under one path:
|
|
25
|
+
# _ = routes.import_("webhook").prefix("/hooks")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Webhooks: which types the endpoint serves, and what each is verified against.
|
|
2
|
+
|
|
3
|
+
One route per sender. The type is the segment in the URL the endpoint serves —
|
|
4
|
+
``POST /webhook/{type}`` — so the subscriber is given that URL and nothing else
|
|
5
|
+
has to change when another sender is added.
|
|
6
|
+
|
|
7
|
+
The secret is read when the endpoint is built, not when the application boots,
|
|
8
|
+
so a placeholder keeps it out of this file and out of every container report.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from xtr_dependency_injection import configure, env
|
|
14
|
+
from xtr_webhook.bundle import RouteConfig, WebhookConfig
|
|
15
|
+
|
|
16
|
+
__all__ = ["webhook"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@configure
|
|
20
|
+
def webhook() -> WebhookConfig:
|
|
21
|
+
"""One type, verified with the bundle's own parser against ``WEBHOOK_SECRET``."""
|
|
22
|
+
return WebhookConfig(
|
|
23
|
+
routing={
|
|
24
|
+
# A route with no parser uses the bundle's own, which verifies the
|
|
25
|
+
# signature_format below. Name a class here for a sender that signs
|
|
26
|
+
# its own way: RouteConfig(parser=AcmeRequestParser, secret=...).
|
|
27
|
+
"acme": RouteConfig(secret=env("WEBHOOK_SECRET")),
|
|
28
|
+
},
|
|
29
|
+
# "legacy", "standard" or "transitional" — both ends must agree. Only
|
|
30
|
+
# the Standard Webhooks signature covers the timestamp, so only it is
|
|
31
|
+
# bounded by timestamp_tolerance.
|
|
32
|
+
signature_format="legacy",
|
|
33
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# The recipe for xtr-webhook: what *Use in an application* in the README takes,
|
|
2
|
+
# as data. Keep the two in step — the README is the source.
|
|
3
|
+
|
|
4
|
+
[bundles]
|
|
5
|
+
# "<module>:<class>" = the environments it is listed with, exactly as in BUNDLES.
|
|
6
|
+
"xtr_webhook.bundle:WebhookBundle" = { all = true }
|
|
7
|
+
|
|
8
|
+
[files]
|
|
9
|
+
# Destination under the application package = template under this recipe.
|
|
10
|
+
# Needed to receive anything: with no routing the endpoint knows no type, and
|
|
11
|
+
# every webhook is answered 404.
|
|
12
|
+
"config/webhook.py" = "files/config/webhook.py.tmpl"
|
|
13
|
+
# Where the endpoint is served: POST /webhook/{type}, with the line that moves
|
|
14
|
+
# it under a prefix written beside it.
|
|
15
|
+
"config/routes/webhook.py" = "files/config/routes/webhook.py.tmpl"
|
|
16
|
+
|
|
17
|
+
[env]
|
|
18
|
+
# The secret the written routing verifies its one example type against. No
|
|
19
|
+
# default to offer — a secret is the sender's — so it is written commented out
|
|
20
|
+
# and stays unset until the sender names one.
|
|
21
|
+
WEBHOOK_SECRET = ""
|
|
22
|
+
|
|
23
|
+
[gitignore]
|
|
24
|
+
# Nothing of its own: a webhook is read and handed on, never written to disk.
|
|
25
|
+
lines = []
|
|
26
|
+
|
|
27
|
+
[notes]
|
|
28
|
+
steps = [
|
|
29
|
+
"Call setup(app, kernel) where the application is built: it adds the endpoint, POST /webhook/{type}, arranged by the written config/routes/webhook.py.",
|
|
30
|
+
"Name each sender's type in config/webhook.py and give its subscribers the URL that type is served at.",
|
|
31
|
+
]
|
|
32
|
+
check = [
|
|
33
|
+
"<script> debug:bundles — webhook and remote_event are listed and active",
|
|
34
|
+
"<script> debug:config webhook — the resolved routing, header names and signature format",
|
|
35
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The endpoint webhooks arrive at, contributed to the application's route table.
|
|
2
|
+
|
|
3
|
+
- :class:`WebhookRouteLoader` — resource ``"webhook"``: ``POST /webhook/{type}``, movable
|
|
4
|
+
under a prefix.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .webhook_route_loader import WebhookRouteLoader
|
|
10
|
+
|
|
11
|
+
__all__ = ["WebhookRouteLoader"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The endpoint webhooks arrive at: a thin adapter handing each one to its controller."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# Every imported name below annotates an endpoint parameter or its return, and
|
|
6
|
+
# both the web framework and the container read those annotations when the
|
|
7
|
+
# application starts, so none of them can hide under TYPE_CHECKING.
|
|
8
|
+
from xtr_dependency_injection import (
|
|
9
|
+
Injected, # noqa: TC002 -- the container reads these endpoint annotations at runtime
|
|
10
|
+
)
|
|
11
|
+
from xtr_http_kernel.bridge import (
|
|
12
|
+
Response, # noqa: TC002 -- the container reads these endpoint annotations at runtime
|
|
13
|
+
)
|
|
14
|
+
from xtr_http_message_contracts import ( # noqa: TC002 -- the container reads these endpoint annotations at runtime
|
|
15
|
+
ServerRequestInterface,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from xtr_webhook.controller.webhook_controller import ( # noqa: TC001 -- the container reads these endpoint annotations at runtime
|
|
19
|
+
WebhookController,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = ["webhook"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def webhook(
|
|
26
|
+
type: str, # noqa: A002 -- the path segment the endpoint is declared with
|
|
27
|
+
controller: Injected[WebhookController],
|
|
28
|
+
request: Injected[ServerRequestInterface],
|
|
29
|
+
response: Injected[Response],
|
|
30
|
+
) -> Response:
|
|
31
|
+
"""Read the webhook that arrived, and answer its sender.
|
|
32
|
+
|
|
33
|
+
One endpoint serves every subscriber. The type in the URL is what tells them
|
|
34
|
+
apart: it picks the parser that reads the webhook and the secret it is
|
|
35
|
+
checked against, both of which are configuration, so a new subscriber needs
|
|
36
|
+
no new route.
|
|
37
|
+
"""
|
|
38
|
+
return await controller.index_action(type, request, response)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The route webhooks arrive at."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from xtr_http_kernel.routing import Route, RouteCollection
|
|
8
|
+
from xtr_http_kernel.routing.decorator import as_route_loader
|
|
9
|
+
|
|
10
|
+
from xtr_webhook._endpoint_paths import ROUTE_NAME, WEBHOOK_PATH
|
|
11
|
+
|
|
12
|
+
from ._endpoints import webhook
|
|
13
|
+
|
|
14
|
+
__all__ = ["WebhookRouteLoader"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@final
|
|
18
|
+
@as_route_loader("webhook")
|
|
19
|
+
class WebhookRouteLoader:
|
|
20
|
+
"""The one endpoint every incoming webhook arrives at, ``POST /webhook/{type}``.
|
|
21
|
+
|
|
22
|
+
An application imports it as ``"webhook"`` and may move it under a prefix
|
|
23
|
+
in a routes file. Wherever it lands, the URL a sender is given has to be the
|
|
24
|
+
path served, so build it from the route's name rather than writing it out.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def load_routes(self) -> RouteCollection:
|
|
28
|
+
"""Return the endpoint at its default path and name."""
|
|
29
|
+
routes = RouteCollection()
|
|
30
|
+
routes.add(ROUTE_NAME, Route(WEBHOOK_PATH, webhook, methods=("POST",)))
|
|
31
|
+
return routes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The sending side: what a webhook leaving this application is signed and shaped like.
|
|
2
|
+
|
|
3
|
+
A webhook is built by a chain of configurators, each writing one part of the same
|
|
4
|
+
:class:`OutgoingRequest` — the headers that name the event, the body the payload is
|
|
5
|
+
written into, the signature over both. They are separate because receivers disagree
|
|
6
|
+
about all three, so an application that has to satisfy an unusual one adds or replaces
|
|
7
|
+
a step rather than the sender.
|
|
8
|
+
|
|
9
|
+
The :class:`Transport` runs that chain and posts the result through an
|
|
10
|
+
:class:`~xtr_http_client_contracts.HttpClientInterface` — the contract alone, so
|
|
11
|
+
everything here imports with no HTTP client installed; xtr-http-client, the ``client``
|
|
12
|
+
extra, is the client.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .header_signature_configurator import HeaderSignatureConfigurator
|
|
18
|
+
from .headers_configurator import HeadersConfigurator
|
|
19
|
+
from .json_body_configurator import JsonBodyConfigurator
|
|
20
|
+
from .json_payload_serializer import JsonPayloadSerializer
|
|
21
|
+
from .outgoing_request import OutgoingRequest
|
|
22
|
+
from .payload_serializer_interface import PayloadSerializerInterface
|
|
23
|
+
from .request_configurator_interface import RequestConfiguratorInterface
|
|
24
|
+
from .signature_format import SignatureFormat
|
|
25
|
+
from .transport import Transport
|
|
26
|
+
from .transport_interface import TransportInterface
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"HeaderSignatureConfigurator",
|
|
30
|
+
"HeadersConfigurator",
|
|
31
|
+
"JsonBodyConfigurator",
|
|
32
|
+
"JsonPayloadSerializer",
|
|
33
|
+
"OutgoingRequest",
|
|
34
|
+
"PayloadSerializerInterface",
|
|
35
|
+
"RequestConfiguratorInterface",
|
|
36
|
+
"SignatureFormat",
|
|
37
|
+
"Transport",
|
|
38
|
+
"TransportInterface",
|
|
39
|
+
]
|