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.
Files changed (56) hide show
  1. xtr_webhook/.agents/skills/xtr-webhook/SKILL.md +350 -0
  2. xtr_webhook/.agents/skills/xtr-webhook/references/signature-formats.md +189 -0
  3. xtr_webhook/__init__.py +10 -0
  4. xtr_webhook/_endpoint_paths.py +22 -0
  5. xtr_webhook/_standard_webhooks_signature.py +76 -0
  6. xtr_webhook/bundle/__init__.py +15 -0
  7. xtr_webhook/bundle/_routed_parsers.py +70 -0
  8. xtr_webhook/bundle/no_private_network_config.py +59 -0
  9. xtr_webhook/bundle/route_config.py +34 -0
  10. xtr_webhook/bundle/webhook_bundle.py +262 -0
  11. xtr_webhook/bundle/webhook_config.py +80 -0
  12. xtr_webhook/client/__init__.py +23 -0
  13. xtr_webhook/client/abstract_request_parser.py +248 -0
  14. xtr_webhook/client/request_parser.py +272 -0
  15. xtr_webhook/client/request_parser_interface.py +90 -0
  16. xtr_webhook/controller/__init__.py +12 -0
  17. xtr_webhook/controller/_built_parsers.py +65 -0
  18. xtr_webhook/controller/webhook_controller.py +140 -0
  19. xtr_webhook/exception/__init__.py +25 -0
  20. xtr_webhook/exception/delivery_error.py +38 -0
  21. xtr_webhook/exception/invalid_argument_error.py +18 -0
  22. xtr_webhook/exception/logic_error.py +15 -0
  23. xtr_webhook/exception/reject_webhook_error.py +52 -0
  24. xtr_webhook/exception/webhook_error.py +9 -0
  25. xtr_webhook/exception/webhook_runtime_error.py +16 -0
  26. xtr_webhook/messenger/__init__.py +14 -0
  27. xtr_webhook/messenger/send_webhook_handler.py +49 -0
  28. xtr_webhook/messenger/send_webhook_message.py +36 -0
  29. xtr_webhook/py.typed +0 -0
  30. xtr_webhook/recipe/__init__.py +18 -0
  31. xtr_webhook/recipe/files/config/routes/webhook.py.tmpl +25 -0
  32. xtr_webhook/recipe/files/config/webhook.py.tmpl +33 -0
  33. xtr_webhook/recipe/manifest.toml +35 -0
  34. xtr_webhook/routing/__init__.py +11 -0
  35. xtr_webhook/routing/_endpoints.py +38 -0
  36. xtr_webhook/routing/webhook_route_loader.py +31 -0
  37. xtr_webhook/server/__init__.py +39 -0
  38. xtr_webhook/server/header_signature_configurator.py +128 -0
  39. xtr_webhook/server/headers_configurator.py +92 -0
  40. xtr_webhook/server/json_body_configurator.py +80 -0
  41. xtr_webhook/server/json_payload_serializer.py +45 -0
  42. xtr_webhook/server/outgoing_request.py +35 -0
  43. xtr_webhook/server/payload_serializer_interface.py +34 -0
  44. xtr_webhook/server/request_configurator_interface.py +39 -0
  45. xtr_webhook/server/signature_format.py +65 -0
  46. xtr_webhook/server/transport.py +106 -0
  47. xtr_webhook/server/transport_interface.py +40 -0
  48. xtr_webhook/subscriber.py +41 -0
  49. xtr_webhook/testing/__init__.py +18 -0
  50. xtr_webhook/testing/_parser_cases.py +141 -0
  51. xtr_webhook/testing/parser_case.py +37 -0
  52. xtr_webhook-4.0.2.dist-info/METADATA +704 -0
  53. xtr_webhook-4.0.2.dist-info/RECORD +56 -0
  54. xtr_webhook-4.0.2.dist-info/WHEEL +4 -0
  55. xtr_webhook-4.0.2.dist-info/entry_points.txt +6 -0
  56. xtr_webhook-4.0.2.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,248 @@
1
+ """Everything a parser of incoming webhooks does the same way as every other."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import TYPE_CHECKING, TypeVar
7
+
8
+ import msgspec
9
+ from typing_extensions import override
10
+ from xtr_http_message_contracts import ResponseInterface
11
+
12
+ from xtr_webhook.exception import RejectWebhookError
13
+
14
+ from .request_parser_interface import RequestParserInterface
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Sequence
18
+
19
+ from xtr_http_foundation import RequestMatcherInterface
20
+ from xtr_http_message_contracts import ServerRequestInterface
21
+ from xtr_remote_event import RemoteEvent
22
+
23
+ __all__ = ["AbstractRequestParser"]
24
+
25
+ R = TypeVar("R", bound=ResponseInterface)
26
+
27
+ _MALFORMED = "Request body is malformed."
28
+ _PLAIN_TEXT = "text/plain; charset=utf-8"
29
+ _UNACCEPTABLE = 406
30
+ _ACCEPTED = 202
31
+
32
+
33
+ class AbstractRequestParser(RequestParserInterface, ABC):
34
+ """A parser with everything but the two things that differ per sender filled in.
35
+
36
+ What is left to a subclass is which requests are its sender's
37
+ (:meth:`get_request_matcher`) and how to check and read one
38
+ (:meth:`do_parse`). Everything around those is the same for every sender,
39
+ and wrong in the same way when each parser writes it again: the matcher has
40
+ to run before anything is read out of the request, an unreadable body has to
41
+ come out as a refusal rather than as a 500, and both answers have to be the
42
+ ones the sender's side is waiting for.
43
+
44
+ The matchers a parser says that with come from
45
+ [xtr-http-foundation](https://pypi.org/project/xtr-http-foundation/):
46
+
47
+ ```python
48
+ class AcmeRequestParser(AbstractRequestParser):
49
+ def get_request_matcher(self) -> RequestMatcherInterface:
50
+ return ChainRequestMatcher([MethodRequestMatcher("POST"), IsJsonRequestMatcher()])
51
+
52
+ async def do_parse(self, request, secret):
53
+ payload = await self.read_json(request)
54
+ return RemoteEvent("acme", str(payload["id"]), payload)
55
+ ```
56
+ """
57
+
58
+ @override
59
+ async def parse(
60
+ self,
61
+ request: ServerRequestInterface,
62
+ secret: str,
63
+ ) -> RemoteEvent | Sequence[RemoteEvent] | None:
64
+ """Check the shape of the request, then hand it to :meth:`do_parse`.
65
+
66
+ A body that will not decode is the sender's doing, not this
67
+ application's, so it comes back as a refusal the endpoint can answer
68
+ with rather than as an error the endpoint would report as its own
69
+ fault.
70
+
71
+ Args:
72
+ request: The request that arrived.
73
+ secret: What the sender's signature is checked against.
74
+
75
+ Returns:
76
+ Whatever :meth:`do_parse` read out of the request.
77
+
78
+ Raises:
79
+ RejectWebhookError: When the request is not one this parser
80
+ claims, when its body will not decode, or when
81
+ :meth:`do_parse` refuses it.
82
+ """
83
+ await self.validate(request)
84
+
85
+ try:
86
+ return await self.do_parse(request, secret)
87
+ except (msgspec.DecodeError, UnicodeDecodeError) as unreadable:
88
+ raise RejectWebhookError(_UNACCEPTABLE, _MALFORMED) from unreadable
89
+
90
+ @override
91
+ async def create_successful_response(self, request: ServerRequestInterface, response: R) -> R:
92
+ """Answer a webhook that was read with 202 and nothing else.
93
+
94
+ The sender is told the webhook was taken, not what became of it: the
95
+ events it carried are handled after the answer has gone, so there is
96
+ nothing yet to report and nothing the sender could do with it.
97
+
98
+ Args:
99
+ request: The request the endpoint received. Unused here; a parser
100
+ whose sender wants something echoed back overrides this.
101
+ response: The response the endpoint built.
102
+
103
+ Returns:
104
+ ``response`` with the status set, its body left empty.
105
+ """
106
+ del request
107
+
108
+ return response.with_status(_ACCEPTED)
109
+
110
+ @override
111
+ async def create_rejected_response(
112
+ self,
113
+ reason: str,
114
+ request: ServerRequestInterface,
115
+ response: R,
116
+ ) -> R:
117
+ """Answer a refused webhook with 406 and the reason as plain text.
118
+
119
+ 406 says the request was understood and still not acceptable, which is
120
+ what a wrong signature, a stale timestamp and an unreadable payload all
121
+ amount to from the sender's side. The reason goes in the body as text
122
+ because that is the one thing a sender's log is likely to keep.
123
+
124
+ Args:
125
+ reason: What to tell the sender.
126
+ request: The request the endpoint received. Unused here.
127
+ response: The response the endpoint built.
128
+
129
+ Returns:
130
+ ``response`` with the status, the content type and the reason on
131
+ it.
132
+ """
133
+ del request
134
+
135
+ refused = response.with_status(_UNACCEPTABLE).with_header("Content-Type", _PLAIN_TEXT)
136
+ _ = await refused.get_body().write(reason.encode())
137
+
138
+ return refused
139
+
140
+ @abstractmethod
141
+ def get_request_matcher(self) -> RequestMatcherInterface:
142
+ """Return what decides whether a request is this parser's sender's.
143
+
144
+ Returns:
145
+ The matcher every request is put through before it is read.
146
+ """
147
+
148
+ @abstractmethod
149
+ async def do_parse(
150
+ self,
151
+ request: ServerRequestInterface,
152
+ secret: str,
153
+ ) -> RemoteEvent | Sequence[RemoteEvent] | None:
154
+ """Check that the request is genuine and read the events out of it.
155
+
156
+ The request has already been through :meth:`get_request_matcher`, so
157
+ its shape is not in question here. Whatever the sender's protocol asks
158
+ for is: comparing a signature over the raw body against a header, in
159
+ constant time, and refusing a timestamp outside the window it allows.
160
+
161
+ Args:
162
+ request: The request that arrived.
163
+ secret: What the sender's signature is checked against.
164
+
165
+ Returns:
166
+ The event the request carried, every event when it carried
167
+ several, or ``None`` when it is one to accept and ignore.
168
+
169
+ Raises:
170
+ RejectWebhookError: On any failure to verify the request.
171
+ """
172
+
173
+ async def validate(self, request: ServerRequestInterface) -> None:
174
+ """Refuse a request this parser's matcher does not claim.
175
+
176
+ Awaited because a matcher may be about the body — whether it reads as
177
+ JSON, above all — which arrives as it is read.
178
+
179
+ Args:
180
+ request: The request that arrived.
181
+
182
+ Raises:
183
+ RejectWebhookError: When the matcher does not claim the request.
184
+ """
185
+ if not await self.get_request_matcher().matches(request):
186
+ raise RejectWebhookError(_UNACCEPTABLE, "Request does not match.")
187
+
188
+ async def read_body(self, request: ServerRequestInterface) -> bytes:
189
+ """Return the body of the request, whole, from its first byte.
190
+
191
+ A signature is made over exactly the bytes that arrived, and whatever
192
+ read the body before this — a framework logging it, a matcher looking
193
+ at it — left the cursor wherever it stopped. Reading the whole of a
194
+ stream rewinds it first, where it can be rewound, so that is all this
195
+ has to ask for.
196
+
197
+ Args:
198
+ request: The request that arrived.
199
+
200
+ Returns:
201
+ Every byte of the body.
202
+ """
203
+ return await request.get_body().get_contents()
204
+
205
+ async def read_json(self, request: ServerRequestInterface) -> dict[str, object]:
206
+ """Return the body of the request read as a JSON object.
207
+
208
+ Args:
209
+ request: The request that arrived.
210
+
211
+ Returns:
212
+ The object the body decoded to, its values left as they came.
213
+
214
+ Raises:
215
+ RejectWebhookError: When the body is valid JSON but not an object
216
+ — an array or a bare value is not a webhook payload.
217
+ msgspec.DecodeError: When the body is not JSON at all. Raised
218
+ rather than refused here, so that :meth:`parse` turns it into
219
+ the same refusal wherever in a subclass it came from.
220
+ """
221
+ return self._decode_json(await self.read_body(request))
222
+
223
+ def _decode_json(self, body: bytes) -> dict[str, object]:
224
+ """Return bytes already read from the body, as a JSON object.
225
+
226
+ What :meth:`read_json` does once the body is in hand, separately, for a
227
+ parser that needs those bytes before it is willing to decode them: a
228
+ signature covers the body, so it has to be read whole and verified
229
+ first, and reading the stream a second time to decode it would be
230
+ decoding something nothing checked.
231
+
232
+ Args:
233
+ body: The bytes of the body, whole.
234
+
235
+ Returns:
236
+ The object they decoded to, their values left as they came.
237
+
238
+ Raises:
239
+ RejectWebhookError: When they are valid JSON but not an object
240
+ — an array or a bare value is not a webhook payload.
241
+ msgspec.DecodeError: When they are not JSON at all. Raised rather
242
+ than refused here, so that :meth:`parse` turns it into the same
243
+ refusal wherever in a subclass it came from.
244
+ """
245
+ try:
246
+ return msgspec.json.decode(body, type=dict[str, object])
247
+ except msgspec.ValidationError as not_an_object:
248
+ raise RejectWebhookError(_UNACCEPTABLE, _MALFORMED) from not_an_object
@@ -0,0 +1,272 @@
1
+ """The parser for webhooks this library's own sender signs, in any of its three formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import re
8
+ from typing import TYPE_CHECKING, Final, final
9
+
10
+ from typing_extensions import override
11
+ from xtr_clock import Clock
12
+ from xtr_http_foundation import ChainRequestMatcher, IsJsonRequestMatcher, MethodRequestMatcher
13
+ from xtr_remote_event import RemoteEvent
14
+
15
+ from xtr_webhook._standard_webhooks_signature import sign
16
+ from xtr_webhook.exception import InvalidArgumentError, RejectWebhookError
17
+ from xtr_webhook.server.signature_format import SignatureFormat
18
+
19
+ from .abstract_request_parser import AbstractRequestParser
20
+
21
+ if TYPE_CHECKING:
22
+ from xtr_clock import ClockInterface
23
+ from xtr_http_foundation import RequestMatcherInterface
24
+ from xtr_http_message_contracts import ServerRequestInterface
25
+
26
+ __all__ = ["RequestParser"]
27
+
28
+ _UNACCEPTABLE: Final = 406
29
+
30
+ _NUMERIC: Final = re.compile(r"^-?\d{1,19}$")
31
+ """What a timestamp this parser can bound looks like: an integer, nothing else.
32
+
33
+ Bounded in length as well as in shape, because this runtime refuses to read a
34
+ long enough digit string as an int at all — and a sender that sent one is owed
35
+ the same refusal as any other unacceptable timestamp, not a server error.
36
+ Nineteen digits reach far past any time the window can accept.
37
+ """
38
+
39
+
40
+ @final
41
+ class RequestParser(AbstractRequestParser):
42
+ """Reads webhooks signed the way this library's own sender signs them.
43
+
44
+ The mirror of the sender's configurators: the same two signature schemes,
45
+ under the same default header names, chosen by the same
46
+ :class:`SignatureFormat <xtr_webhook.server.SignatureFormat>`. Under the
47
+ transitional format either scheme authenticates a request, which is what
48
+ lets the two ends migrate on different days.
49
+
50
+ The signature header may carry several whitespace-separated entries — one
51
+ per secret during a rotation — and any one that verifies is enough. When a
52
+ Standard Webhooks entry verifies, the timestamp it covers is then held to a
53
+ window around now, so a delivery captured once cannot be fed back later.
54
+ """
55
+
56
+ def __init__( # noqa: PLR0913, PLR0917 — every name and bound has a default both ends share.
57
+ self,
58
+ algorithm: str = "sha256",
59
+ signature_header_name: str = "Webhook-Signature",
60
+ event_header_name: str = "Webhook-Event",
61
+ id_header_name: str = "Webhook-Id",
62
+ timestamp_header_name: str = "Webhook-Timestamp",
63
+ signature_format: SignatureFormat | str = SignatureFormat.LEGACY,
64
+ timestamp_tolerance: int = 300,
65
+ clock: ClockInterface | None = None,
66
+ ) -> None:
67
+ """Configure what to verify against, under which names and bounds.
68
+
69
+ Args:
70
+ algorithm: The digest the legacy signature is recomputed with. The
71
+ Standard Webhooks signature is always SHA-256; the scheme fixes it.
72
+ signature_header_name: The header the signature entries arrive in.
73
+ event_header_name: The header the event's name arrives in, where the
74
+ format sends one.
75
+ id_header_name: The header the event's id arrives in.
76
+ timestamp_header_name: The header the signed timestamp arrives in.
77
+ signature_format: Which scheme a request must carry. The string a
78
+ configuration carries is accepted.
79
+ timestamp_tolerance: How many seconds either side of now a signed
80
+ timestamp may be. Zero or less turns the bound off — for a
81
+ replayed capture to matter less than clocks that cannot be kept
82
+ close enough.
83
+ clock: What now is measured by. ``None`` reads whichever clock is in
84
+ force when a request arrives, so a test can freeze it without
85
+ rebuilding the parser.
86
+
87
+ Raises:
88
+ InvalidArgumentError: When this runtime cannot make ``algorithm``, or
89
+ when ``signature_format`` names no format. Both are caught here
90
+ rather than at the first webhook: a receiver that cannot
91
+ recompute a signature is misconfigured, and finding out when one
92
+ arrives means refusing a sender for this side's mistake.
93
+ """
94
+ if algorithm not in hashlib.algorithms_available:
95
+ raise InvalidArgumentError(f'The signing algorithm "{algorithm}" is not available.')
96
+
97
+ self._algorithm = algorithm
98
+ self._signature_header_name = signature_header_name
99
+ self._event_header_name = event_header_name
100
+ self._id_header_name = id_header_name
101
+ self._timestamp_header_name = timestamp_header_name
102
+ self._signature_format = SignatureFormat.parse(signature_format)
103
+ self._timestamp_tolerance = timestamp_tolerance
104
+ self._clock = clock
105
+
106
+ @override
107
+ def get_request_matcher(self) -> RequestMatcherInterface:
108
+ """Claim only a ``POST`` whose body reads as JSON, as the sender's side emits.
109
+
110
+ The body itself decides, not the ``Content-Type`` header: a sender that
111
+ announces nothing still sent JSON, and one that announces JSON and sent
112
+ something else did not.
113
+ """
114
+ return ChainRequestMatcher([MethodRequestMatcher("POST"), IsJsonRequestMatcher()])
115
+
116
+ @override
117
+ async def do_parse(self, request: ServerRequestInterface, secret: str) -> RemoteEvent:
118
+ """Check that the request is genuine and read the one event it carries.
119
+
120
+ In that order, and over one read of the body: nothing decodes it until a
121
+ signature over those exact bytes has verified, so a request that is not
122
+ the sender's is answered on its signature rather than on whatever its
123
+ body turned out to be — which is both what the sender needs told and the
124
+ answer that says least about what this side does with a payload.
125
+
126
+ Args:
127
+ request: The request that arrived, already claimed by the matcher.
128
+ secret: What the signature entries are checked against.
129
+
130
+ Returns:
131
+ The event, named by the payload's ``type`` key when the Standard
132
+ Webhooks entry authenticated the request — that scheme signs no
133
+ header, so a name sent as one cannot be trusted — and by the event
134
+ header otherwise, which the legacy scheme does sign.
135
+
136
+ Raises:
137
+ InvalidArgumentError: When ``secret`` is empty. That is this side's
138
+ configuration, not the sender's doing, so it surfaces as a
139
+ server error rather than as a refusal.
140
+ RejectWebhookError: When a required header is missing, no signature
141
+ entry verifies, the signed timestamp is outside the window, the
142
+ body is not a JSON object, or the payload names no event.
143
+ """
144
+ if not secret:
145
+ raise InvalidArgumentError("A non-empty secret is required.")
146
+
147
+ body = await self.read_body(request)
148
+
149
+ required = [self._signature_header_name, self._id_header_name]
150
+ if self._signature_format is SignatureFormat.LEGACY:
151
+ required.append(self._event_header_name)
152
+ elif self._signature_format is SignatureFormat.STANDARD:
153
+ required.append(self._timestamp_header_name)
154
+
155
+ for name in required:
156
+ if not request.has_header(name):
157
+ raise RejectWebhookError(
158
+ _UNACCEPTABLE, f'Missing "{name}" HTTP request signature header.'
159
+ )
160
+
161
+ verified = self._verify_signature(request, body, secret)
162
+ payload = self._decode_json(body)
163
+
164
+ return RemoteEvent(
165
+ self._event_name(payload)
166
+ if verified is SignatureFormat.STANDARD
167
+ else request.get_header_line(self._event_header_name),
168
+ request.get_header_line(self._id_header_name),
169
+ payload,
170
+ )
171
+
172
+ def _verify_signature(
173
+ self, request: ServerRequestInterface, body: bytes, secret: str
174
+ ) -> SignatureFormat:
175
+ """Return the scheme of the entry that authenticated the request.
176
+
177
+ The header may carry several whitespace-separated entries, one per
178
+ secret during a rotation. Each expectation is computed once, before its
179
+ loop, so the number of entries a request carries does not drive the CPU
180
+ cost of rejecting it; every comparison is :func:`hmac.compare_digest`,
181
+ so the time one takes says nothing about how much of it was right.
182
+
183
+ Args:
184
+ request: The request that arrived.
185
+ body: Its body, whole — the exact bytes the sender signed.
186
+ secret: What the entries are checked against.
187
+
188
+ Raises:
189
+ RejectWebhookError: When no entry verifies, or when the one that did
190
+ covers a timestamp outside the window.
191
+ """
192
+ candidates = request.get_header_line(self._signature_header_name).split()
193
+ message_id = request.get_header_line(self._id_header_name)
194
+
195
+ # The Standard Webhooks entry is tried first, so that a request carrying
196
+ # both schemes is replay-bounded whatever order its entries arrive in.
197
+ if self._signature_format is not SignatureFormat.LEGACY and request.has_header(
198
+ self._timestamp_header_name
199
+ ):
200
+ timestamp = request.get_header_line(self._timestamp_header_name)
201
+ expected = sign(message_id, timestamp, body, secret).encode()
202
+
203
+ for candidate in candidates:
204
+ if hmac.compare_digest(expected, candidate.encode()):
205
+ self._check_timestamp(timestamp)
206
+
207
+ return SignatureFormat.STANDARD
208
+
209
+ if self._signature_format is not SignatureFormat.STANDARD and request.has_header(
210
+ self._event_header_name
211
+ ):
212
+ event = request.get_header_line(self._event_header_name)
213
+ digest = hmac.new(
214
+ secret.encode(), event.encode() + message_id.encode() + body, self._algorithm
215
+ )
216
+ expected = f"{self._algorithm}={digest.hexdigest()}".encode()
217
+
218
+ for candidate in candidates:
219
+ if hmac.compare_digest(expected, candidate.encode()):
220
+ return SignatureFormat.LEGACY
221
+
222
+ raise RejectWebhookError(_UNACCEPTABLE, "Signature is wrong.")
223
+
224
+ def _check_timestamp(self, timestamp: str) -> None:
225
+ """Refuse a signed timestamp outside the window around now.
226
+
227
+ Run only after a Standard Webhooks entry verified, because only then is
228
+ the timestamp known to be the sender's rather than an attacker's.
229
+
230
+ Args:
231
+ timestamp: What the timestamp header carried, already signed.
232
+
233
+ Raises:
234
+ RejectWebhookError: When the timestamp does not read as an integer
235
+ this runtime will convert — a value that cannot be bounded is
236
+ not accepted on faith, and one too long to read is refused as
237
+ such rather than raised over — or lies further than the
238
+ tolerance from now.
239
+ """
240
+ if self._timestamp_tolerance <= 0:
241
+ return
242
+
243
+ now = int((self._clock or Clock.get()).now().timestamp())
244
+
245
+ if (
246
+ _NUMERIC.match(timestamp) is None
247
+ or abs(now - int(timestamp)) > self._timestamp_tolerance
248
+ ):
249
+ raise RejectWebhookError(
250
+ _UNACCEPTABLE,
251
+ f'The "{self._timestamp_header_name}" HTTP request header is outside the '
252
+ "accepted window.",
253
+ )
254
+
255
+ def _event_name(self, payload: dict[str, object]) -> str:
256
+ """Return the event's name from the payload's ``type`` key.
257
+
258
+ Args:
259
+ payload: The body, already decoded.
260
+
261
+ Raises:
262
+ RejectWebhookError: When the key is missing or not a string — the
263
+ scheme that signed this request puts the name there, so a
264
+ payload without one names no event.
265
+ """
266
+ name = payload.get("type")
267
+ if not isinstance(name, str):
268
+ raise RejectWebhookError(
269
+ _UNACCEPTABLE, 'The payload is missing the "type" key holding the event name.'
270
+ )
271
+
272
+ return name
@@ -0,0 +1,90 @@
1
+ """What reads an incoming webhook into events, and answers its sender."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol, TypeVar, runtime_checkable
6
+
7
+ from xtr_http_message_contracts import ResponseInterface
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Sequence
11
+
12
+ from xtr_http_message_contracts import ServerRequestInterface
13
+ from xtr_remote_event import RemoteEvent
14
+
15
+ __all__ = ["RequestParserInterface"]
16
+
17
+ R = TypeVar("R", bound=ResponseInterface)
18
+
19
+
20
+ @runtime_checkable
21
+ class RequestParserInterface(Protocol):
22
+ """Reads an incoming webhook into events, and refuses the ones that are not genuine.
23
+
24
+ One parser speaks for one sender: it knows which requests are that sender's,
25
+ how that sender signs them, and what its payload means. Everything a sender
26
+ can get wrong — a signature that does not match, a timestamp outside the
27
+ window, a body that is not readable — is one refusal, so the endpoint in
28
+ front of it has a single thing to answer with.
29
+
30
+ The endpoint owns the response: an HTTP message is immutable, so a parser
31
+ cannot build one out of nothing and still be sure it carries what the
32
+ application puts on every response. It is handed one and gives back the one
33
+ it changed.
34
+ """
35
+
36
+ async def parse(
37
+ self,
38
+ request: ServerRequestInterface,
39
+ secret: str,
40
+ ) -> RemoteEvent | Sequence[RemoteEvent] | None:
41
+ """Read a request into the events it carries.
42
+
43
+ Args:
44
+ request: The request that arrived.
45
+ secret: What the sender's signature is checked against.
46
+
47
+ Returns:
48
+ The event the request carried, every event when it carried
49
+ several, or ``None`` when it is one to accept and ignore — a
50
+ sender's keep-alive, an event kind this parser does not speak for.
51
+
52
+ Raises:
53
+ RejectWebhookError: When the request is refused, whatever the
54
+ reason: a signature that does not match, a replayed timestamp,
55
+ an unreadable payload.
56
+ """
57
+ ...
58
+
59
+ async def create_successful_response(self, request: ServerRequestInterface, response: R) -> R:
60
+ """Fill in the answer to a webhook that was read.
61
+
62
+ Args:
63
+ request: The request the endpoint received, for a parser whose
64
+ sender wants something of its own echoed back.
65
+ response: The response the endpoint built, to change and give back.
66
+
67
+ Returns:
68
+ The response to send, which is ``response`` with the answer on it.
69
+ """
70
+ ...
71
+
72
+ async def create_rejected_response(
73
+ self,
74
+ reason: str,
75
+ request: ServerRequestInterface,
76
+ response: R,
77
+ ) -> R:
78
+ """Fill in the answer to a webhook that was refused.
79
+
80
+ Args:
81
+ reason: What to tell the sender. A sender is not debugged by the
82
+ receiver, so this is the refusal itself, never what gave it
83
+ away.
84
+ request: The request the endpoint received.
85
+ response: The response the endpoint built, to change and give back.
86
+
87
+ Returns:
88
+ The response to send, which is ``response`` with the refusal on it.
89
+ """
90
+ ...
@@ -0,0 +1,12 @@
1
+ """The endpoint every incoming webhook arrives at.
2
+
3
+ One endpoint for every subscriber: the type in the URL picks the parser that reads
4
+ the webhook and the secret it is checked against, so the events it carried reach a
5
+ worker and the sender is answered straight away.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .webhook_controller import WebhookController
11
+
12
+ __all__ = ["WebhookController"]
@@ -0,0 +1,65 @@
1
+ """Parsers already built, handed over by the type of webhook each reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import MappingProxyType
6
+ from typing import TYPE_CHECKING, final
7
+
8
+ from typing_extensions import override
9
+ from xtr_service_contracts import ServiceProviderInterface
10
+
11
+ from xtr_webhook.exception import LogicError
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Hashable, Mapping
15
+
16
+ from xtr_webhook.client import RequestParserInterface
17
+
18
+ __all__ = ["BuiltParsers"]
19
+
20
+
21
+ @final
22
+ class BuiltParsers(ServiceProviderInterface["RequestParserInterface"]):
23
+ """Hands over parsers from a mapping — what stands in where there is no container.
24
+
25
+ The controller asks for a parser by type and does not care where it came
26
+ from: a container's locator, which builds each parser with its own
27
+ dependencies the first time a webhook for it arrives, or a mapping of
28
+ parsers built by hand. This is the second of those, so the controller has
29
+ one thing to ask rather than two.
30
+ """
31
+
32
+ __slots__ = ("_parsers",)
33
+
34
+ def __init__(self, parsers: Mapping[str, RequestParserInterface]) -> None:
35
+ """Hand over ``parsers``, by the type of webhook each reads.
36
+
37
+ The mapping is copied: which parser speaks for a type is fixed once
38
+ the endpoint is built, so a caller still writing to its own dict
39
+ cannot change who reads a subscriber's webhooks halfway through a
40
+ deploy's life.
41
+ """
42
+ self._parsers = dict(parsers)
43
+
44
+ @override
45
+ async def get(self, name: Hashable, /) -> RequestParserInterface:
46
+ """Return the parser reading webhooks of the type ``name``.
47
+
48
+ Raises:
49
+ LogicError: If there is none. The endpoint asks :meth:`has` first
50
+ and answers 404 itself, so reaching this means the two were
51
+ asked different questions — a mistake in the code, not a
52
+ webhook naming a type nobody subscribed to.
53
+ """
54
+ parser = self._parsers.get(str(name))
55
+ if parser is None:
56
+ raise LogicError(f'Unable to find a parser for the webhook type "{name}".')
57
+ return parser
58
+
59
+ @override
60
+ def has(self, name: Hashable, /) -> bool:
61
+ return str(name) in self._parsers
62
+
63
+ @override
64
+ def provided_services(self) -> Mapping[Hashable, type[object]]:
65
+ return MappingProxyType({name: type(parser) for name, parser in self._parsers.items()})