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,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,9 @@
1
+ """The error every other error of this package derives from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["WebhookError"]
6
+
7
+
8
+ class WebhookError(Exception):
9
+ """Something a webhook could not be — catch this to catch them all."""
@@ -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
+ ]