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,70 @@
|
|
|
1
|
+
"""The parser each configured webhook type is read by, reached through the container."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
from typing import TYPE_CHECKING, Final, 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_dependency_injection import ServiceLocator
|
|
17
|
+
|
|
18
|
+
from xtr_webhook.client.request_parser_interface import RequestParserInterface
|
|
19
|
+
|
|
20
|
+
__all__ = ["ROUTE_QUALIFIER", "RoutedParsers"]
|
|
21
|
+
|
|
22
|
+
#: The qualifier each configured type's parser is aliased under. Apart from the
|
|
23
|
+
#: bundle's own ``webhook`` qualifier, so a type named ``webhook`` reads with the
|
|
24
|
+
#: parser its route names, and no type is served that routing did not name.
|
|
25
|
+
ROUTE_QUALIFIER: Final = "webhook.route.{type}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@final
|
|
29
|
+
class RoutedParsers(ServiceProviderInterface["RequestParserInterface"]):
|
|
30
|
+
"""Hands the controller the parser of each type the routing names, and no other.
|
|
31
|
+
|
|
32
|
+
The container's locator over every parser is filtered down to the types
|
|
33
|
+
the routing names, each reached under its own qualifier, so a parser — and
|
|
34
|
+
what it needs — is built the first time a webhook of its type arrives.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("_locator", "_qualifiers")
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
locator: ServiceLocator[RequestParserInterface],
|
|
42
|
+
types: tuple[str, ...],
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Serve ``types``, each read through ``locator`` under its route's qualifier."""
|
|
45
|
+
self._locator = locator
|
|
46
|
+
self._qualifiers = {name: ROUTE_QUALIFIER.format(type=name) for name in types}
|
|
47
|
+
|
|
48
|
+
@override
|
|
49
|
+
async def get(self, name: Hashable, /) -> RequestParserInterface:
|
|
50
|
+
"""Return the parser reading webhooks of the type ``name``.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
LogicError: If the routing names no such type. The endpoint asks
|
|
54
|
+
:meth:`has` first and answers 404 itself.
|
|
55
|
+
"""
|
|
56
|
+
qualifier = self._qualifiers.get(str(name))
|
|
57
|
+
if qualifier is None:
|
|
58
|
+
raise LogicError(f'Unable to find a parser for the webhook type "{name}".')
|
|
59
|
+
return await self._locator.get(qualifier)
|
|
60
|
+
|
|
61
|
+
@override
|
|
62
|
+
def has(self, name: Hashable, /) -> bool:
|
|
63
|
+
return str(name) in self._qualifiers
|
|
64
|
+
|
|
65
|
+
@override
|
|
66
|
+
def provided_services(self) -> Mapping[Hashable, type[object]]:
|
|
67
|
+
provided = self._locator.provided_services()
|
|
68
|
+
return MappingProxyType(
|
|
69
|
+
{name: provided[qualifier] for name, qualifier in self._qualifiers.items()}
|
|
70
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""What the outbound guard refuses, and the few addresses it still allows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import final
|
|
8
|
+
|
|
9
|
+
from xtr_webhook.exception import InvalidArgumentError
|
|
10
|
+
|
|
11
|
+
__all__ = ["NoPrivateNetworkConfig"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@final
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class NoPrivateNetworkConfig:
|
|
17
|
+
"""Whether the client webhooks are sent through refuses to deliver into a private network.
|
|
18
|
+
|
|
19
|
+
Off by default: a receiver on this network is a legitimate setup until the
|
|
20
|
+
application says otherwise. Turned on, it wraps that client in
|
|
21
|
+
:class:`~xtr_http_client.NoPrivateNetworkHttpClient`, which checks what
|
|
22
|
+
every destination *resolves to* before anything is sent.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
enabled: Whether outgoing webhooks are vetted at all.
|
|
26
|
+
subnets: The networks to refuse, in CIDR notation, replacing the
|
|
27
|
+
default. ``None`` refuses every address that is not globally
|
|
28
|
+
routable — loopback, private, link-local, carrier-grade NAT,
|
|
29
|
+
unique-local, multicast, reserved and unspecified alike.
|
|
30
|
+
allow_list: Networks or single addresses exempt from the refusal.
|
|
31
|
+
Addresses only, never hostnames: the check runs on resolved
|
|
32
|
+
addresses, and a name could point anywhere by the time it is used.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
enabled: bool = False
|
|
36
|
+
subnets: tuple[str, ...] | None = None
|
|
37
|
+
allow_list: tuple[str, ...] = ()
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
"""Refuse an entry that names no network, and tuning on a guard that is off.
|
|
41
|
+
|
|
42
|
+
The second check exists because this configuration cannot imply
|
|
43
|
+
consent: naming subnets does not turn the guard on, so accepting them
|
|
44
|
+
while it is off would leave an application believing it is protected.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
InvalidArgumentError: When ``subnets`` or ``allow_list`` is given
|
|
48
|
+
while ``enabled`` is ``False``, or an entry is not an IP
|
|
49
|
+
network or address.
|
|
50
|
+
"""
|
|
51
|
+
if not self.enabled and (self.subnets is not None or self.allow_list):
|
|
52
|
+
raise InvalidArgumentError(
|
|
53
|
+
"no_private_network.subnets and allow_list take effect only with enabled=True.",
|
|
54
|
+
)
|
|
55
|
+
for entry in (*(self.subnets or ()), *self.allow_list):
|
|
56
|
+
try:
|
|
57
|
+
_ = ipaddress.ip_network(entry, strict=False)
|
|
58
|
+
except ValueError as error:
|
|
59
|
+
raise InvalidArgumentError(f'"{entry}" is not an IP network.') from error
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""One webhook type's route: which parser reads it, and what verifies it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import final
|
|
7
|
+
|
|
8
|
+
from xtr_webhook.client.request_parser_interface import RequestParserInterface
|
|
9
|
+
|
|
10
|
+
__all__ = ["RouteConfig"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@final
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class RouteConfig:
|
|
16
|
+
"""What the endpoint does with webhooks of one type.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
parser: The class reading this type's webhooks, built by the container
|
|
20
|
+
with its dependencies on the first webhook for it. ``None`` means
|
|
21
|
+
the bundle's own configured
|
|
22
|
+
:class:`~xtr_webhook.client.RequestParser`, which verifies the
|
|
23
|
+
signature scheme the bundle's ``signature_format`` names.
|
|
24
|
+
secret: What this type's webhooks are verified against. Pass it
|
|
25
|
+
through the container's ``env("...")`` placeholder, never as a
|
|
26
|
+
literal: a placeholder is read only when the endpoint is built,
|
|
27
|
+
and no container report prints it. It must be set in production —
|
|
28
|
+
with an empty value, depending on the parser, requests from any
|
|
29
|
+
sender are accepted or every request is rejected. Kept out of the
|
|
30
|
+
``repr``.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
parser: type[RequestParserInterface] | None = None
|
|
34
|
+
secret: str = field(default="", repr=False)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""The xtr-webhook bundle: the receiving endpoint and the signing sender, from one config.
|
|
2
|
+
|
|
3
|
+
An application listing :class:`WebhookBundle` — which brings the remote-event,
|
|
4
|
+
messenger and HTTP kernel bundles with it, and the HTTP client bundle when
|
|
5
|
+
xtr-http-client is installed — gets the endpoint webhooks arrive at, which
|
|
6
|
+
``setup`` mounts, and its controller, a configured
|
|
7
|
+
:class:`~xtr_webhook.client.RequestParser` under the ``webhook`` qualifier,
|
|
8
|
+
and — with xtr-http-client — the transport that delivers a
|
|
9
|
+
``SendWebhookMessage`` and the handler that listens for one. The handler is
|
|
10
|
+
registered only alongside the transport it needs, so a container never holds a
|
|
11
|
+
handler it cannot build; without the ``client`` extra, dispatching a
|
|
12
|
+
``SendWebhookMessage`` fails with the bus's no-handler error.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from importlib.util import find_spec
|
|
18
|
+
from typing import TYPE_CHECKING, Annotated, Final, final
|
|
19
|
+
|
|
20
|
+
from typing_extensions import override
|
|
21
|
+
from xtr_dependency_injection import (
|
|
22
|
+
Bundle,
|
|
23
|
+
ContainerBuilder,
|
|
24
|
+
ServiceConfigurator,
|
|
25
|
+
ServiceLocator,
|
|
26
|
+
Target,
|
|
27
|
+
as_bundle,
|
|
28
|
+
named_factory,
|
|
29
|
+
required_bundle,
|
|
30
|
+
)
|
|
31
|
+
from xtr_http_client_contracts import HttpClientInterface
|
|
32
|
+
from xtr_http_kernel.bundle import HttpKernelBundle
|
|
33
|
+
from xtr_messenger import MessageBusInterface
|
|
34
|
+
from xtr_remote_event.bundle import RemoteEventBundle
|
|
35
|
+
|
|
36
|
+
from xtr_webhook.client.request_parser import RequestParser
|
|
37
|
+
from xtr_webhook.client.request_parser_interface import RequestParserInterface
|
|
38
|
+
from xtr_webhook.controller.webhook_controller import WebhookController
|
|
39
|
+
from xtr_webhook.exception import LogicError
|
|
40
|
+
from xtr_webhook.server.header_signature_configurator import HeaderSignatureConfigurator
|
|
41
|
+
from xtr_webhook.server.headers_configurator import HeadersConfigurator
|
|
42
|
+
from xtr_webhook.server.json_body_configurator import JsonBodyConfigurator
|
|
43
|
+
from xtr_webhook.server.json_payload_serializer import JsonPayloadSerializer
|
|
44
|
+
from xtr_webhook.server.signature_format import SignatureFormat
|
|
45
|
+
from xtr_webhook.server.transport import Transport
|
|
46
|
+
from xtr_webhook.server.transport_interface import TransportInterface
|
|
47
|
+
|
|
48
|
+
from ._routed_parsers import ROUTE_QUALIFIER, RoutedParsers
|
|
49
|
+
from .webhook_config import WebhookConfig
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from collections.abc import Callable
|
|
53
|
+
|
|
54
|
+
__all__ = ["WebhookBundle"]
|
|
55
|
+
|
|
56
|
+
_WEBHOOK: Final = "webhook"
|
|
57
|
+
"""The qualifier of the bundle's own parser."""
|
|
58
|
+
|
|
59
|
+
_SEND_HANDLER_MODULE: Final = "xtr_webhook.messenger.send_webhook_handler"
|
|
60
|
+
|
|
61
|
+
_ROUTING_MODULE: Final = "xtr_webhook.routing"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@final
|
|
65
|
+
@required_bundle(RemoteEventBundle)
|
|
66
|
+
@required_bundle(HttpKernelBundle)
|
|
67
|
+
@required_bundle("xtr_http_client.bundle:HttpClientBundle", ignore_on_invalid=True)
|
|
68
|
+
@as_bundle("webhook", config=WebhookConfig)
|
|
69
|
+
class WebhookBundle(Bundle[WebhookConfig]):
|
|
70
|
+
"""Wires the endpoint's controller and the sender's chain to the container."""
|
|
71
|
+
|
|
72
|
+
@override
|
|
73
|
+
def load_extension(
|
|
74
|
+
self,
|
|
75
|
+
config: WebhookConfig,
|
|
76
|
+
services: ServiceConfigurator,
|
|
77
|
+
builder: ContainerBuilder,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Register the configurators, the parser, the controller and the sending side.
|
|
80
|
+
|
|
81
|
+
The format is converted once, here: the configurators and the parser
|
|
82
|
+
take a member, and every factory below is handed the same one.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
LogicError: When the private-network guard is turned on while
|
|
86
|
+
xtr-http-client, whose client it wraps, is not installed.
|
|
87
|
+
"""
|
|
88
|
+
del builder
|
|
89
|
+
fmt = SignatureFormat.parse(config.signature_format)
|
|
90
|
+
|
|
91
|
+
_ = services.set(named_factory(_headers_configurator(fmt), "webhook_headers_configurator"))
|
|
92
|
+
_ = services.set(named_factory(_json_body_configurator(fmt), "webhook_body_configurator"))
|
|
93
|
+
_ = services.set(named_factory(_signer(fmt), "webhook_signer"))
|
|
94
|
+
|
|
95
|
+
_ = services.set(
|
|
96
|
+
named_factory(_request_parser(fmt), "webhook_request_parser"), qualifier=_WEBHOOK
|
|
97
|
+
)
|
|
98
|
+
services.alias(
|
|
99
|
+
RequestParserInterface,
|
|
100
|
+
RequestParser,
|
|
101
|
+
alias_qualifier=_WEBHOOK,
|
|
102
|
+
target_qualifier=_WEBHOOK,
|
|
103
|
+
)
|
|
104
|
+
for parser in _custom_parsers(config):
|
|
105
|
+
_ = services.set(parser)
|
|
106
|
+
# Each configured type's parser, under a qualifier of its own: the
|
|
107
|
+
# controller reaches them through a locator the container injects.
|
|
108
|
+
for name, route in config.routing.items():
|
|
109
|
+
services.alias(
|
|
110
|
+
RequestParserInterface,
|
|
111
|
+
route.parser if route.parser is not None else RequestParser,
|
|
112
|
+
alias_qualifier=ROUTE_QUALIFIER.format(type=name),
|
|
113
|
+
target_qualifier=None if route.parser is not None else _WEBHOOK,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
_ = services.set(named_factory(_webhook_controller, "webhook_controller"))
|
|
117
|
+
# The route loader: scanned so the http kernel builds the endpoint into
|
|
118
|
+
# the application's table, which setup() mounts.
|
|
119
|
+
services.load(_ROUTING_MODULE)
|
|
120
|
+
|
|
121
|
+
if find_spec("xtr_http_client") is not None:
|
|
122
|
+
_ = services.set(named_factory(_transport(config.http_client), "webhook_transport"))
|
|
123
|
+
services.alias(TransportInterface, Transport)
|
|
124
|
+
# The handler needs the transport registered just above, so it is
|
|
125
|
+
# registered with the bus only here: a container must never hold a
|
|
126
|
+
# handler it cannot build.
|
|
127
|
+
services.load(_SEND_HANDLER_MODULE)
|
|
128
|
+
elif config.no_private_network.enabled:
|
|
129
|
+
raise LogicError(
|
|
130
|
+
'Configuring "no_private_network" requires xtr-http-client; '
|
|
131
|
+
'install it with uv add "xtr-webhook[client]".'
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _custom_parsers(config: WebhookConfig) -> tuple[type[RequestParserInterface], ...]:
|
|
136
|
+
"""Return each distinct parser class the routing names, in first-use order.
|
|
137
|
+
|
|
138
|
+
Distinct and once: two routes naming one class share one instance, so what
|
|
139
|
+
the parser keeps between webhooks does not depend on the route they used.
|
|
140
|
+
"""
|
|
141
|
+
seen: dict[type[RequestParserInterface], None] = {}
|
|
142
|
+
for route in config.routing.values():
|
|
143
|
+
if route.parser is not None:
|
|
144
|
+
seen.setdefault(route.parser)
|
|
145
|
+
return tuple(seen)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _headers_configurator(fmt: SignatureFormat) -> Callable[[WebhookConfig], HeadersConfigurator]:
|
|
149
|
+
"""Return the factory of the step writing the identifying headers.
|
|
150
|
+
|
|
151
|
+
The configuration is injected rather than closed over: the container
|
|
152
|
+
hands the factory a copy with every environment placeholder resolved.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def build(config: WebhookConfig) -> HeadersConfigurator:
|
|
156
|
+
return HeadersConfigurator(
|
|
157
|
+
event_header_name=config.event_header_name,
|
|
158
|
+
id_header_name=config.id_header_name,
|
|
159
|
+
timestamp_header_name=config.timestamp_header_name,
|
|
160
|
+
signature_format=fmt,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return build
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _json_body_configurator(fmt: SignatureFormat) -> Callable[[], JsonBodyConfigurator]:
|
|
167
|
+
"""Return the factory of the step writing the payload as a JSON body."""
|
|
168
|
+
|
|
169
|
+
def build() -> JsonBodyConfigurator:
|
|
170
|
+
return JsonBodyConfigurator(JsonPayloadSerializer(), fmt)
|
|
171
|
+
|
|
172
|
+
return build
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _signer(fmt: SignatureFormat) -> Callable[[WebhookConfig], HeaderSignatureConfigurator]:
|
|
176
|
+
"""Return the factory of the step signing what the other two wrote."""
|
|
177
|
+
|
|
178
|
+
def build(config: WebhookConfig) -> HeaderSignatureConfigurator:
|
|
179
|
+
return HeaderSignatureConfigurator(
|
|
180
|
+
algorithm=config.signing_algorithm,
|
|
181
|
+
signature_header_name=config.signature_header_name,
|
|
182
|
+
signature_format=fmt,
|
|
183
|
+
timestamp_header_name=config.timestamp_header_name,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return build
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _transport(qualifier: str | None) -> Callable[..., Transport]:
|
|
190
|
+
"""Return the factory of the delivery, over the client ``qualifier`` names.
|
|
191
|
+
|
|
192
|
+
The qualifier is configuration, so the client's annotation is written onto
|
|
193
|
+
the factory at runtime rather than in its signature; ``None`` asks for the
|
|
194
|
+
default client. The guard is read from the injected configuration, and
|
|
195
|
+
wraps whichever client that is.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def build(
|
|
199
|
+
config: WebhookConfig,
|
|
200
|
+
client: HttpClientInterface,
|
|
201
|
+
headers: HeadersConfigurator,
|
|
202
|
+
body: JsonBodyConfigurator,
|
|
203
|
+
signer: HeaderSignatureConfigurator,
|
|
204
|
+
) -> Transport:
|
|
205
|
+
guard = config.no_private_network
|
|
206
|
+
if guard.enabled:
|
|
207
|
+
# Imported only now: the factory is registered only once
|
|
208
|
+
# xtr-http-client was found, and the guard is that package's.
|
|
209
|
+
from xtr_http_client import ( # noqa: PLC0415 -- guarded behind the client extra
|
|
210
|
+
NoPrivateNetworkHttpClient,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
client = NoPrivateNetworkHttpClient(client, guard.subnets, guard.allow_list)
|
|
214
|
+
|
|
215
|
+
return Transport(client, headers, body, signer)
|
|
216
|
+
|
|
217
|
+
build.__annotations__ = {
|
|
218
|
+
"config": WebhookConfig,
|
|
219
|
+
"client": (
|
|
220
|
+
HttpClientInterface
|
|
221
|
+
if qualifier is None
|
|
222
|
+
else Annotated[HttpClientInterface, Target(qualifier)]
|
|
223
|
+
),
|
|
224
|
+
"headers": HeadersConfigurator,
|
|
225
|
+
"body": JsonBodyConfigurator,
|
|
226
|
+
"signer": HeaderSignatureConfigurator,
|
|
227
|
+
"return": Transport,
|
|
228
|
+
}
|
|
229
|
+
return build
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _request_parser(fmt: SignatureFormat) -> Callable[[WebhookConfig], RequestParser]:
|
|
233
|
+
"""Return the factory of the bundle's own parser, the mirror of its sender."""
|
|
234
|
+
|
|
235
|
+
def build(config: WebhookConfig) -> RequestParser:
|
|
236
|
+
return RequestParser(
|
|
237
|
+
algorithm=config.signing_algorithm,
|
|
238
|
+
signature_header_name=config.signature_header_name,
|
|
239
|
+
event_header_name=config.event_header_name,
|
|
240
|
+
id_header_name=config.id_header_name,
|
|
241
|
+
timestamp_header_name=config.timestamp_header_name,
|
|
242
|
+
signature_format=fmt,
|
|
243
|
+
timestamp_tolerance=config.timestamp_tolerance,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return build
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _webhook_controller(
|
|
250
|
+
config: WebhookConfig,
|
|
251
|
+
bus: MessageBusInterface,
|
|
252
|
+
parsers: ServiceLocator[RequestParserInterface],
|
|
253
|
+
) -> WebhookController:
|
|
254
|
+
"""Build the endpoint's controller over the parsers the routing names.
|
|
255
|
+
|
|
256
|
+
The container injects a locator over every parser; only the routed types
|
|
257
|
+
are handed on, each under the qualifier its route was aliased with, so a
|
|
258
|
+
route's parser is built — with its dependencies — the first time a webhook
|
|
259
|
+
for it arrives. A route naming no parser reads with the bundle's own.
|
|
260
|
+
"""
|
|
261
|
+
secrets = {name: route.secret for name, route in config.routing.items()}
|
|
262
|
+
return WebhookController(RoutedParsers(parsers, tuple(config.routing)), secrets, bus)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The one configuration an application writes to receive and send webhooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import final
|
|
9
|
+
|
|
10
|
+
from xtr_webhook.exception import InvalidArgumentError
|
|
11
|
+
from xtr_webhook.server.signature_format import SignatureFormat
|
|
12
|
+
|
|
13
|
+
from .no_private_network_config import NoPrivateNetworkConfig
|
|
14
|
+
from .route_config import RouteConfig
|
|
15
|
+
|
|
16
|
+
__all__ = ["WebhookConfig"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _no_routes() -> dict[str, RouteConfig]:
|
|
20
|
+
"""The empty routing a zero-config application starts with."""
|
|
21
|
+
return {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@final
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class WebhookConfig:
|
|
27
|
+
"""Values the bundle builds the endpoint's and the sender's services from.
|
|
28
|
+
|
|
29
|
+
Buildable with no arguments: the zero-config path routes nothing, signs
|
|
30
|
+
with the legacy scheme under the default header names, and sends through
|
|
31
|
+
the application's default HTTP client.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
http_client: The qualifier of the
|
|
35
|
+
:class:`~xtr_http_client_contracts.HttpClientInterface` webhooks are
|
|
36
|
+
sent through — a scoped client of the HTTP client configuration.
|
|
37
|
+
``None`` means the default, unqualified one.
|
|
38
|
+
no_private_network: Whether that client is wrapped in a guard refusing
|
|
39
|
+
to deliver into a private network, and what counts as one.
|
|
40
|
+
event_header_name: The header the event's name travels in, where the
|
|
41
|
+
signature scheme sends one.
|
|
42
|
+
id_header_name: The header the event's id travels in.
|
|
43
|
+
timestamp_header_name: The header the send time travels in.
|
|
44
|
+
signature_header_name: The header the signature entries travel in.
|
|
45
|
+
signing_algorithm: The digest the legacy signature is made with.
|
|
46
|
+
signature_format: The signature scheme both ends must share — a
|
|
47
|
+
:class:`~xtr_webhook.server.SignatureFormat` or the string one
|
|
48
|
+
carries. Validated here; readers convert with
|
|
49
|
+
:meth:`SignatureFormat.parse <xtr_webhook.server.SignatureFormat.parse>`.
|
|
50
|
+
timestamp_tolerance: How many seconds either side of now an incoming
|
|
51
|
+
signed timestamp may be. Zero turns the replay bound off.
|
|
52
|
+
routing: What the endpoint does with each webhook type, by the type in
|
|
53
|
+
the URL. Keys are kept verbatim — dashes too.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
http_client: str | None = None
|
|
57
|
+
no_private_network: NoPrivateNetworkConfig = field(default_factory=NoPrivateNetworkConfig)
|
|
58
|
+
event_header_name: str = "Webhook-Event"
|
|
59
|
+
id_header_name: str = "Webhook-Id"
|
|
60
|
+
timestamp_header_name: str = "Webhook-Timestamp"
|
|
61
|
+
signature_header_name: str = "Webhook-Signature"
|
|
62
|
+
signing_algorithm: str = "sha256"
|
|
63
|
+
signature_format: SignatureFormat | str = SignatureFormat.LEGACY
|
|
64
|
+
timestamp_tolerance: int = 300
|
|
65
|
+
routing: Mapping[str, RouteConfig] = field(default_factory=_no_routes)
|
|
66
|
+
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
"""Refuse values no service built from them could honour.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
InvalidArgumentError: When the tolerance is negative, the
|
|
72
|
+
algorithm is not available, or the format names no scheme.
|
|
73
|
+
"""
|
|
74
|
+
if self.timestamp_tolerance < 0:
|
|
75
|
+
raise InvalidArgumentError("timestamp_tolerance must be greater than or equal to 0.")
|
|
76
|
+
if self.signing_algorithm not in hashlib.algorithms_available:
|
|
77
|
+
raise InvalidArgumentError(
|
|
78
|
+
f'The signing algorithm "{self.signing_algorithm}" is not available.',
|
|
79
|
+
)
|
|
80
|
+
_ = SignatureFormat.parse(self.signature_format)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The receiving side: what an incoming webhook is checked against and read into.
|
|
2
|
+
|
|
3
|
+
One parser speaks for one sender. It states which requests are that sender's with a
|
|
4
|
+
matcher, checks that one is genuine, and gives back the events it carried — or refuses
|
|
5
|
+
it, with the answer its sender is to be given.
|
|
6
|
+
|
|
7
|
+
The matchers themselves come from
|
|
8
|
+
[xtr-http-foundation](https://pypi.org/project/xtr-http-foundation/), so a webhook
|
|
9
|
+
endpoint says which requests are its sender's the same way a firewall or a rate limiter
|
|
10
|
+
does.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .abstract_request_parser import AbstractRequestParser
|
|
16
|
+
from .request_parser import RequestParser
|
|
17
|
+
from .request_parser_interface import RequestParserInterface
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AbstractRequestParser",
|
|
21
|
+
"RequestParser",
|
|
22
|
+
"RequestParserInterface",
|
|
23
|
+
]
|