webhook-co 0.1.0__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.
webhook_co/__init__.py ADDED
@@ -0,0 +1,90 @@
1
+ """The official Python SDK for the webhook.co API.
2
+
3
+ from webhook_co import WebhookClient
4
+
5
+ client = WebhookClient(api_key="whk_...")
6
+ for endpoint in client.endpoints.list():
7
+ print(endpoint.id, endpoint.name)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ._errors import (
13
+ WebhookAPIError,
14
+ WebhookAuthenticationError,
15
+ WebhookConfigError,
16
+ WebhookConflictError,
17
+ WebhookConnectionError,
18
+ WebhookError,
19
+ WebhookInvalidRequestError,
20
+ WebhookNotFoundError,
21
+ WebhookPermissionError,
22
+ WebhookRateLimitError,
23
+ WebhookTargetUnreachableError,
24
+ WebhookUnexpectedResponseError,
25
+ )
26
+ from ._generated.models import (
27
+ AddedProviderSecret,
28
+ AuditVerifyResponse,
29
+ AuthContext,
30
+ CreatedEndpoint,
31
+ CreatedReplayDestination,
32
+ DeletedEndpoint,
33
+ Delivery,
34
+ DeliveryAttempt,
35
+ Endpoint,
36
+ Event,
37
+ EventsTailResponse,
38
+ EventSummary,
39
+ ProviderSecretSummary,
40
+ ReplayDestination,
41
+ ReplayDestinationDeleted,
42
+ RevokedProviderSecret,
43
+ RotatedSigningSecret,
44
+ SigningSecretMetadata,
45
+ Subscription,
46
+ SubscriptionDeleted,
47
+ )
48
+ from ._pagination import Page, Paginator
49
+ from .client import EventPayload, WebhookClient
50
+
51
+ __all__ = [
52
+ "WebhookClient",
53
+ "EventPayload",
54
+ "Page",
55
+ "Paginator",
56
+ # errors
57
+ "WebhookError",
58
+ "WebhookConfigError",
59
+ "WebhookConnectionError",
60
+ "WebhookAPIError",
61
+ "WebhookAuthenticationError",
62
+ "WebhookPermissionError",
63
+ "WebhookNotFoundError",
64
+ "WebhookInvalidRequestError",
65
+ "WebhookConflictError",
66
+ "WebhookRateLimitError",
67
+ "WebhookTargetUnreachableError",
68
+ "WebhookUnexpectedResponseError",
69
+ # models
70
+ "Endpoint",
71
+ "CreatedEndpoint",
72
+ "DeletedEndpoint",
73
+ "Event",
74
+ "EventSummary",
75
+ "EventsTailResponse",
76
+ "Delivery",
77
+ "DeliveryAttempt",
78
+ "ReplayDestination",
79
+ "CreatedReplayDestination",
80
+ "ReplayDestinationDeleted",
81
+ "RotatedSigningSecret",
82
+ "SigningSecretMetadata",
83
+ "Subscription",
84
+ "SubscriptionDeleted",
85
+ "AddedProviderSecret",
86
+ "ProviderSecretSummary",
87
+ "RevokedProviderSecret",
88
+ "AuditVerifyResponse",
89
+ "AuthContext",
90
+ ]
webhook_co/_config.py ADDED
@@ -0,0 +1,40 @@
1
+ """Client configuration resolution.
2
+
3
+ The base URL is the destination for the bearer credential, so it MUST be https — plaintext http is
4
+ rejected except for loopback (local dev / self-host) — otherwise a misconfiguration could downgrade the
5
+ key to plaintext or redirect it to a hostile host. The URL must be a clean origin+path (no query/
6
+ fragment); the normalised form is returned.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from urllib.parse import urlsplit
12
+
13
+ from ._errors import WebhookConfigError
14
+
15
+ #: The canonical hosted API (mirrors the OpenAPI ``servers`` entry).
16
+ DEFAULT_BASE_URL = "https://api.webhook.co"
17
+
18
+ _LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
19
+
20
+
21
+ def resolve_base_url(raw: str | None) -> str:
22
+ """Validate + normalise a base URL (defaulting to the hosted API).
23
+
24
+ https-only except loopback http; no query/fragment; a trailing slash is stripped so
25
+ ``f"{base}/v1/…"`` concatenation is always well-formed.
26
+ """
27
+ value = raw or DEFAULT_BASE_URL
28
+ parts = urlsplit(value)
29
+ if not parts.scheme or not parts.netloc:
30
+ raise WebhookConfigError(f"invalid base URL: {value}")
31
+ loopback_http_ok = parts.scheme == "http" and parts.hostname in _LOOPBACK_HOSTS
32
+ if parts.scheme != "https" and not loopback_http_ok:
33
+ raise WebhookConfigError(
34
+ f"base URL must be https (got {parts.scheme}://): {value}"
35
+ )
36
+ if parts.query or parts.fragment:
37
+ raise WebhookConfigError(
38
+ f"base URL must not carry a query or fragment: {value}"
39
+ )
40
+ return f"{parts.scheme}://{parts.netloc}{parts.path}".rstrip("/")
webhook_co/_errors.py ADDED
@@ -0,0 +1,158 @@
1
+ """The SDK's typed error surface.
2
+
3
+ Every failure a caller can catch is a :class:`WebhookError`; the subclasses let callers narrow with
4
+ ``except`` without string-matching a code. The taxonomy mirrors the API's three error representations (a
5
+ JSON ``{error,message}`` envelope, an empty-body 401/403, and a text/plain router miss) — the HTTP core
6
+ resolves those into one of these before raising.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Literal
12
+
13
+ WebhookErrorCode = Literal[
14
+ "VALIDATION_ERROR",
15
+ "UNAUTHORIZED",
16
+ "FORBIDDEN",
17
+ "NOT_FOUND",
18
+ "ENDPOINT_PAUSED",
19
+ "RATE_LIMITED",
20
+ "TARGET_UNREACHABLE",
21
+ ]
22
+
23
+ _STATUS_TO_CODE: dict[int, WebhookErrorCode] = {
24
+ 400: "VALIDATION_ERROR",
25
+ 401: "UNAUTHORIZED",
26
+ 403: "FORBIDDEN",
27
+ 404: "NOT_FOUND",
28
+ 409: "ENDPOINT_PAUSED",
29
+ 429: "RATE_LIMITED",
30
+ 502: "TARGET_UNREACHABLE",
31
+ }
32
+
33
+ #: A concise default message per code, used when the server sends no ``message`` (e.g. an empty-body 401).
34
+ DEFAULT_MESSAGE: dict[WebhookErrorCode, str] = {
35
+ "UNAUTHORIZED": "authentication failed — the API key is invalid, expired, or revoked",
36
+ "FORBIDDEN": "the API key is missing a required scope for this action",
37
+ "NOT_FOUND": "the requested resource was not found",
38
+ "VALIDATION_ERROR": "the request was rejected as invalid",
39
+ "RATE_LIMITED": "rate limited — retry after a short delay",
40
+ "ENDPOINT_PAUSED": "the endpoint is paused",
41
+ "TARGET_UNREACHABLE": "the delivery target was unreachable",
42
+ }
43
+
44
+
45
+ def code_for_status(status: int) -> WebhookErrorCode | None:
46
+ """Resolve the capability code for an HTTP status, or ``None`` when the status isn't modelled."""
47
+ return _STATUS_TO_CODE.get(status)
48
+
49
+
50
+ class WebhookError(Exception):
51
+ """Base class for every error the SDK raises. Catch this to handle any SDK failure uniformly."""
52
+
53
+ def __init__(
54
+ self,
55
+ message: str,
56
+ *,
57
+ code: WebhookErrorCode | None = None,
58
+ status: int | None = None,
59
+ request_id: str | None = None,
60
+ retry_after_s: float | None = None,
61
+ ) -> None:
62
+ super().__init__(message)
63
+ self.message = message
64
+ self.code = code
65
+ self.status = status
66
+ self.request_id = request_id
67
+ self.retry_after_s = retry_after_s
68
+
69
+
70
+ class WebhookConfigError(WebhookError):
71
+ """The SDK was constructed with invalid options (e.g. a plaintext base URL). Raised eagerly."""
72
+
73
+
74
+ class WebhookConnectionError(WebhookError):
75
+ """The request never reached the server (DNS/TLS/connection failure or a client-side timeout)."""
76
+
77
+
78
+ class WebhookAPIError(WebhookError):
79
+ """The server returned an HTTP error response. Always carries a ``status``."""
80
+
81
+
82
+ class WebhookAuthenticationError(WebhookAPIError):
83
+ """401 — the credential is invalid, expired, or revoked."""
84
+
85
+
86
+ class WebhookPermissionError(WebhookAPIError):
87
+ """403 — the credential lacks a scope required for the action."""
88
+
89
+
90
+ class WebhookNotFoundError(WebhookAPIError):
91
+ """404 — the resource does not exist (or is not visible to this org)."""
92
+
93
+
94
+ class WebhookInvalidRequestError(WebhookAPIError):
95
+ """400 — the request was structurally or semantically invalid."""
96
+
97
+
98
+ class WebhookConflictError(WebhookAPIError):
99
+ """409 — the endpoint is paused (a conflicting state)."""
100
+
101
+
102
+ class WebhookRateLimitError(WebhookAPIError):
103
+ """429 — rate limited; inspect ``retry_after_s``."""
104
+
105
+
106
+ class WebhookTargetUnreachableError(WebhookAPIError):
107
+ """502 — the downstream delivery target was unreachable."""
108
+
109
+
110
+ class WebhookUnexpectedResponseError(WebhookError):
111
+ """A response the SDK can't interpret: an unmodelled error status, or a body that failed validation."""
112
+
113
+
114
+ _CODE_TO_CLASS: dict[WebhookErrorCode, type[WebhookAPIError]] = {
115
+ "VALIDATION_ERROR": WebhookInvalidRequestError,
116
+ "UNAUTHORIZED": WebhookAuthenticationError,
117
+ "FORBIDDEN": WebhookPermissionError,
118
+ "NOT_FOUND": WebhookNotFoundError,
119
+ "ENDPOINT_PAUSED": WebhookConflictError,
120
+ "RATE_LIMITED": WebhookRateLimitError,
121
+ "TARGET_UNREACHABLE": WebhookTargetUnreachableError,
122
+ }
123
+
124
+
125
+ def error_from_response(
126
+ *,
127
+ status: int,
128
+ code: WebhookErrorCode | None = None,
129
+ message: str | None = None,
130
+ request_id: str | None = None,
131
+ retry_after_s: float | None = None,
132
+ ) -> WebhookError:
133
+ """Build the typed error for a failed response (message/code already redacted by the caller).
134
+
135
+ A body code (when present) wins over the status-derived code; a modelled code selects its subclass;
136
+ anything unmodelled becomes an unexpected-response error.
137
+ """
138
+ resolved = code or code_for_status(status)
139
+ if resolved is not None:
140
+ cls = _CODE_TO_CLASS[resolved]
141
+ return cls(
142
+ message or DEFAULT_MESSAGE[resolved],
143
+ code=resolved,
144
+ status=status,
145
+ request_id=request_id,
146
+ retry_after_s=retry_after_s,
147
+ )
148
+ return WebhookUnexpectedResponseError(
149
+ message or f"the API returned an unexpected response (HTTP {status})",
150
+ status=status,
151
+ request_id=request_id,
152
+ retry_after_s=retry_after_s,
153
+ )
154
+
155
+
156
+ def error_from_transport(base_url: str) -> WebhookConnectionError:
157
+ """Build a connection error for a transport failure. The cause is omitted so no raw string can leak."""
158
+ return WebhookConnectionError(f"could not reach the API at {base_url}")
@@ -0,0 +1 @@
1
+ """Generated pydantic models — do not edit; run scripts/generate_models.py."""