hookbase 2.8.0__tar.gz → 3.0.0__tar.gz
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.
- {hookbase-2.8.0 → hookbase-3.0.0}/PKG-INFO +1 -1
- hookbase-3.0.0/src/hookbase/_version.py +1 -0
- hookbase-3.0.0/src/hookbase/models/sources.py +180 -0
- hookbase-3.0.0/tests/resources/test_source_fields.py +105 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/tests/resources/test_sources.py +6 -3
- hookbase-2.8.0/src/hookbase/_version.py +0 -1
- hookbase-2.8.0/src/hookbase/models/sources.py +0 -119
- {hookbase-2.8.0 → hookbase-3.0.0}/.github/workflows/python-publish.yml +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/.gitignore +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/LICENSE +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/README.md +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/examples/async_usage.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/examples/basic_inbound.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/examples/error_handling.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/examples/send_webhooks.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/examples/verify_webhooks.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/pyproject.toml +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/__init__.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/_client.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/_constants.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/_pagination.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/client.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/errors.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/__init__.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/_base.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/analytics.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/api_keys.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/applications.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/common.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/cron_jobs.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/deliveries.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/destinations.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/dlq.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/endpoints.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/event_types.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/events.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/filters.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/messages.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/organizations.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/portal_tokens.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/routes.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/schemas.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/subscriptions.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/transforms.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/models/tunnels.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/py.typed +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/__init__.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/_base.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/analytics.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/api_keys.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/applications.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/cron_jobs.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/deliveries.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/destinations.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/dlq.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/endpoints.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/event_types.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/events.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/filters.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/message_log.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/messages.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/organizations.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/portal_tokens.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/routes.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/schemas.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/sources.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/subscriptions.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/transforms.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/resources/tunnels.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/src/hookbase/webhook.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/tests/__init__.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/tests/conftest.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/tests/resources/__init__.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/tests/resources/test_applications.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/tests/resources/test_cron_jobs.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/tests/resources/test_destinations.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/tests/resources/test_dlq.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/tests/resources/test_messages.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/tests/test_client.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/tests/test_errors.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/tests/test_pagination.py +0 -0
- {hookbase-2.8.0 → hookbase-3.0.0}/tests/test_webhook.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "3.0.0"
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import field_validator
|
|
7
|
+
|
|
8
|
+
from ._base import HookbaseModel
|
|
9
|
+
|
|
10
|
+
# Providers the API will accept for a source.
|
|
11
|
+
#
|
|
12
|
+
# Mirrors SUPPORTED_SIGNATURE_PROVIDERS in the API (api/src/utils/signature-schemes.ts), which
|
|
13
|
+
# is derived from the signature scheme table rather than restated. Regenerate with
|
|
14
|
+
# `npx tsx scripts/print-source-enums.ts` in the api package; do not hand-extend this Literal,
|
|
15
|
+
# because a value the API does not accept is a 400 the type promised would not happen.
|
|
16
|
+
#
|
|
17
|
+
# Removed in the 2026-09 correction: "sendgrid", "mailgun" and "linear". All three were
|
|
18
|
+
# advertised here from the beginning and none was ever accepted by the API. SendGrid signs with
|
|
19
|
+
# ECDSA and Mailgun puts the signature in the POST body, so neither fits this scheme model;
|
|
20
|
+
# Linear's scheme is real but unverified against its docs. Use "custom" for all three.
|
|
21
|
+
#
|
|
22
|
+
# "svix" is an alias of "standard-webhooks"; both resolve to the same scheme server-side.
|
|
23
|
+
SourceProvider = Literal[
|
|
24
|
+
"airtable", "asana", "bitbucket", "calendly", "custom", "generic",
|
|
25
|
+
"github", "gitlab", "heroku", "intercom", "lemonsqueezy", "notion",
|
|
26
|
+
"paddle", "razorpay", "sentry", "shopify", "slack", "standard-webhooks",
|
|
27
|
+
"stripe", "svix", "twilio", "typeform", "workos", "zoom",
|
|
28
|
+
]
|
|
29
|
+
# Corrected alongside SourceProvider: "header" and "event_id" were never accepted, and the three
|
|
30
|
+
# values the API actually defaults to and documents were missing. "auto" is the default.
|
|
31
|
+
DedupStrategy = Literal["auto", "provider_id", "payload_hash", "idempotency_key", "none"]
|
|
32
|
+
IpFilterMode = Literal["none", "allowlist", "denylist", "both"]
|
|
33
|
+
# HTTP verbs an ingest endpoint can be restricted to. OPTIONS is excluded: CORS preflight is
|
|
34
|
+
# answered before ingest runs, so it is never gateable.
|
|
35
|
+
IngestMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Source(HookbaseModel):
|
|
39
|
+
"""A source as the API returns it.
|
|
40
|
+
|
|
41
|
+
The signing secret is not included. Responses carry ``has_signing_secret`` and the last four
|
|
42
|
+
characters; the full value comes back exactly twice, from :meth:`Sources.create` (which
|
|
43
|
+
returns a :class:`SourceWithSecret`) and from :meth:`Sources.reveal_secret`.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
id: str
|
|
47
|
+
organization_id: str | None = None
|
|
48
|
+
name: str
|
|
49
|
+
slug: str
|
|
50
|
+
provider: SourceProvider | None = None
|
|
51
|
+
description: str | None = None
|
|
52
|
+
#: Whether a signing secret is set. The secret itself is not returned here.
|
|
53
|
+
has_signing_secret: bool = False
|
|
54
|
+
#: Last four characters of the signing secret, prefixed with "...", or None if unset.
|
|
55
|
+
signing_secret_last4: str | None = None
|
|
56
|
+
#: Reject events whose signature fails verification. Defaults to False, in which case a
|
|
57
|
+
#: failing event is flagged with signature_valid=False and delivered anyway.
|
|
58
|
+
reject_invalid_signatures: bool = False
|
|
59
|
+
rate_limit_per_minute: int | None = None
|
|
60
|
+
is_active: bool = True
|
|
61
|
+
custom_domain_id: str | None = None
|
|
62
|
+
ip_filter_mode: IpFilterMode = "none"
|
|
63
|
+
ip_allowlist: list[str] = []
|
|
64
|
+
ip_denylist: list[str] = []
|
|
65
|
+
#: JSONPath expressions whose values are encrypted at rest.
|
|
66
|
+
encrypt_fields: list[str] = []
|
|
67
|
+
#: JSONPath expressions whose values are masked in stored payloads.
|
|
68
|
+
mask_fields: list[str] = []
|
|
69
|
+
dedup_enabled: bool = False
|
|
70
|
+
dedup_strategy: DedupStrategy = "auto"
|
|
71
|
+
dedup_window_hours: int = 24
|
|
72
|
+
dedup_custom_header: str | None = None
|
|
73
|
+
transient_mode: bool = False
|
|
74
|
+
#: HTTP verbs the ingest endpoint accepts. Empty list means any method.
|
|
75
|
+
allowed_methods: list[str] = []
|
|
76
|
+
event_count: int = 0
|
|
77
|
+
route_count: int = 0
|
|
78
|
+
#: Returned by get() and create(). Not included in list() responses.
|
|
79
|
+
ingest_url: str | None = None
|
|
80
|
+
created_at: str = ""
|
|
81
|
+
updated_at: str = ""
|
|
82
|
+
|
|
83
|
+
@field_validator(
|
|
84
|
+
"is_active",
|
|
85
|
+
"reject_invalid_signatures",
|
|
86
|
+
"has_signing_secret",
|
|
87
|
+
"dedup_enabled",
|
|
88
|
+
"transient_mode",
|
|
89
|
+
mode="before",
|
|
90
|
+
)
|
|
91
|
+
@classmethod
|
|
92
|
+
def parse_bool_from_int(cls, v: Any) -> Any:
|
|
93
|
+
if isinstance(v, int):
|
|
94
|
+
return bool(v)
|
|
95
|
+
return v
|
|
96
|
+
|
|
97
|
+
@field_validator(
|
|
98
|
+
"ip_allowlist", "ip_denylist", "encrypt_fields", "mask_fields", mode="before"
|
|
99
|
+
)
|
|
100
|
+
@classmethod
|
|
101
|
+
def parse_json_list(cls, v: Any) -> Any:
|
|
102
|
+
if v is None:
|
|
103
|
+
return []
|
|
104
|
+
if isinstance(v, str):
|
|
105
|
+
try:
|
|
106
|
+
parsed = json.loads(v)
|
|
107
|
+
return parsed if isinstance(parsed, list) else []
|
|
108
|
+
except (json.JSONDecodeError, ValueError):
|
|
109
|
+
return []
|
|
110
|
+
return v
|
|
111
|
+
|
|
112
|
+
@field_validator("allowed_methods", mode="before")
|
|
113
|
+
@classmethod
|
|
114
|
+
def parse_allowed_methods(cls, v: Any) -> Any:
|
|
115
|
+
"""Coerce null/JSON-string forms to a list; both mean "any method" when empty."""
|
|
116
|
+
if v is None:
|
|
117
|
+
return []
|
|
118
|
+
if isinstance(v, str):
|
|
119
|
+
try:
|
|
120
|
+
parsed = json.loads(v)
|
|
121
|
+
return parsed if isinstance(parsed, list) else []
|
|
122
|
+
except (json.JSONDecodeError, ValueError):
|
|
123
|
+
return []
|
|
124
|
+
return v
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SourceWithSecret(Source):
|
|
128
|
+
"""What create() returns: a source plus the one look at its signing secret you get."""
|
|
129
|
+
|
|
130
|
+
signing_secret: str = ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class CreateSourceParams(HookbaseModel):
|
|
134
|
+
name: str
|
|
135
|
+
#: Required: it forms the ingest URL, /ingest/<org>/<slug>, and cannot be changed later.
|
|
136
|
+
slug: str
|
|
137
|
+
provider: SourceProvider | None = None
|
|
138
|
+
description: str | None = None
|
|
139
|
+
#: Supply your own secret to match what the provider is already configured with. Omit it and
|
|
140
|
+
#: the API generates one, returned once on the created source.
|
|
141
|
+
signing_secret: str | None = None
|
|
142
|
+
#: Reject events whose signature fails verification. Defaults to False.
|
|
143
|
+
reject_invalid_signatures: bool | None = None
|
|
144
|
+
rate_limit_per_minute: int | None = None
|
|
145
|
+
ip_filter_mode: IpFilterMode | None = None
|
|
146
|
+
ip_allowlist: list[str] | None = None
|
|
147
|
+
ip_denylist: list[str] | None = None
|
|
148
|
+
encrypt_fields: list[str] | None = None
|
|
149
|
+
mask_fields: list[str] | None = None
|
|
150
|
+
dedup_enabled: bool | None = None
|
|
151
|
+
dedup_strategy: DedupStrategy | None = None
|
|
152
|
+
#: Deduplication window in hours, 1 to 168.
|
|
153
|
+
dedup_window_hours: int | None = None
|
|
154
|
+
#: Header to deduplicate on when dedup_strategy is "idempotency_key".
|
|
155
|
+
dedup_custom_header: str | None = None
|
|
156
|
+
transient_mode: bool | None = None
|
|
157
|
+
#: Restrict the ingest endpoint to these verbs. None/[] accepts any method.
|
|
158
|
+
allowed_methods: list[IngestMethod] | None = None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class UpdateSourceParams(HookbaseModel):
|
|
162
|
+
name: str | None = None
|
|
163
|
+
description: str | None = None
|
|
164
|
+
provider: SourceProvider | None = None
|
|
165
|
+
is_active: bool | None = None
|
|
166
|
+
signing_secret: str | None = None
|
|
167
|
+
reject_invalid_signatures: bool | None = None
|
|
168
|
+
rate_limit_per_minute: int | None = None
|
|
169
|
+
ip_filter_mode: IpFilterMode | None = None
|
|
170
|
+
ip_allowlist: list[str] | None = None
|
|
171
|
+
ip_denylist: list[str] | None = None
|
|
172
|
+
encrypt_fields: list[str] | None = None
|
|
173
|
+
mask_fields: list[str] | None = None
|
|
174
|
+
dedup_enabled: bool | None = None
|
|
175
|
+
dedup_strategy: DedupStrategy | None = None
|
|
176
|
+
dedup_window_hours: int | None = None
|
|
177
|
+
dedup_custom_header: str | None = None
|
|
178
|
+
transient_mode: bool | None = None
|
|
179
|
+
#: Restrict the ingest endpoint to these verbs. Pass [] to accept any method again.
|
|
180
|
+
allowed_methods: list[IngestMethod] | None = None
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""The field names this SDK puts on the wire, as POST/PATCH /api/sources accept them.
|
|
2
|
+
|
|
3
|
+
These lists exist because they were wrong for months and nothing said so. The models named
|
|
4
|
+
``verify_signature``, ``dedup_window``, ``dedup_header_name``, ``rate_limit`` and
|
|
5
|
+
``rate_limit_window``, which the camelCase alias generator sent as ``verifySignature`` and
|
|
6
|
+
friends. The API's create schema was a plain zod object, which strips unknown keys rather than
|
|
7
|
+
refusing them, so every one of those calls returned 201 having quietly dropped the setting: a
|
|
8
|
+
source created with ``verify_signature=True`` came back with verification off and raised no error.
|
|
9
|
+
|
|
10
|
+
The create schema is strict now, so a stale name is a 400 rather than a silent drop — which is why
|
|
11
|
+
a rename here has to be deliberate.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
from pydantic import ValidationError
|
|
18
|
+
|
|
19
|
+
from hookbase.models.sources import (
|
|
20
|
+
CreateSourceParams,
|
|
21
|
+
Source,
|
|
22
|
+
SourceWithSecret,
|
|
23
|
+
UpdateSourceParams,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
CREATE_FIELDS = {
|
|
27
|
+
"name", "slug", "provider", "description", "signingSecret",
|
|
28
|
+
"rejectInvalidSignatures", "rateLimitPerMinute", "ipFilterMode", "ipAllowlist",
|
|
29
|
+
"ipDenylist", "encryptFields", "maskFields", "dedupEnabled", "dedupStrategy",
|
|
30
|
+
"dedupWindowHours", "dedupCustomHeader", "transientMode", "allowedMethods",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
UPDATE_FIELDS = (CREATE_FIELDS - {"slug"}) | {"isActive"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _aliases(model: type) -> set[str]:
|
|
37
|
+
return {f.alias or name for name, f in model.model_fields.items()}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_create_params_send_exactly_the_accepted_fields() -> None:
|
|
41
|
+
assert _aliases(CreateSourceParams) == CREATE_FIELDS
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_update_params_send_exactly_the_accepted_fields() -> None:
|
|
45
|
+
assert _aliases(UpdateSourceParams) == UPDATE_FIELDS
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_create_and_update_differ_only_by_slug_and_is_active() -> None:
|
|
49
|
+
create, update = _aliases(CreateSourceParams), _aliases(UpdateSourceParams)
|
|
50
|
+
assert create - update == {"slug"}
|
|
51
|
+
assert update - create == {"isActive"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_slug_is_required_on_create() -> None:
|
|
55
|
+
"""slug forms the ingest URL and cannot be changed afterwards, so create demands it."""
|
|
56
|
+
with pytest.raises(ValidationError, match="slug"):
|
|
57
|
+
CreateSourceParams(name="No slug")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_a_full_create_serializes_every_field_under_its_wire_name() -> None:
|
|
61
|
+
params = CreateSourceParams(
|
|
62
|
+
name="Stripe Production",
|
|
63
|
+
slug="stripe",
|
|
64
|
+
provider="stripe",
|
|
65
|
+
description="Live charge events",
|
|
66
|
+
signing_secret="whsec_abc",
|
|
67
|
+
reject_invalid_signatures=True,
|
|
68
|
+
rate_limit_per_minute=600,
|
|
69
|
+
ip_filter_mode="allowlist",
|
|
70
|
+
ip_allowlist=["203.0.113.0/24"],
|
|
71
|
+
ip_denylist=[],
|
|
72
|
+
encrypt_fields=["$.data.object.customer_email"],
|
|
73
|
+
mask_fields=["$.data.object.last4"],
|
|
74
|
+
dedup_enabled=True,
|
|
75
|
+
dedup_strategy="provider_id",
|
|
76
|
+
dedup_window_hours=24,
|
|
77
|
+
dedup_custom_header="X-Idempotency-Key",
|
|
78
|
+
transient_mode=False,
|
|
79
|
+
allowed_methods=["POST"],
|
|
80
|
+
)
|
|
81
|
+
assert set(params.model_dump(by_alias=True, exclude_none=True)) == CREATE_FIELDS
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_a_listed_source_carries_the_masked_pair_not_the_secret() -> None:
|
|
85
|
+
src = Source(
|
|
86
|
+
id="src_1", name="Stripe", slug="stripe",
|
|
87
|
+
hasSigningSecret=True, signingSecretLast4="...c123",
|
|
88
|
+
rejectInvalidSignatures=True, dedupWindowHours=24, routeCount=3,
|
|
89
|
+
)
|
|
90
|
+
assert src.has_signing_secret is True
|
|
91
|
+
assert src.signing_secret_last4 == "...c123"
|
|
92
|
+
assert src.reject_invalid_signatures is True
|
|
93
|
+
assert src.dedup_window_hours == 24
|
|
94
|
+
assert src.route_count == 3
|
|
95
|
+
assert not hasattr(src, "signing_secret")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_a_created_source_carries_the_secret_and_the_ingest_url() -> None:
|
|
99
|
+
created = SourceWithSecret(
|
|
100
|
+
id="src_1", name="Stripe", slug="stripe",
|
|
101
|
+
signingSecret="whsec_abc",
|
|
102
|
+
ingestUrl="https://api.hookbase.app/ingest/acme/stripe",
|
|
103
|
+
)
|
|
104
|
+
assert created.signing_secret == "whsec_abc"
|
|
105
|
+
assert created.ingest_url is not None and "/ingest/" in created.ingest_url
|
|
@@ -30,10 +30,13 @@ SOURCE_DATA = {
|
|
|
30
30
|
"slug": "github",
|
|
31
31
|
"provider": "github",
|
|
32
32
|
"isActive": True,
|
|
33
|
-
"
|
|
33
|
+
"hasSigningSecret": True,
|
|
34
|
+
"signingSecretLast4": "...c123",
|
|
35
|
+
"rejectInvalidSignatures": True,
|
|
34
36
|
"dedupStrategy": "none",
|
|
35
37
|
"ipFilterMode": "none",
|
|
36
38
|
"eventCount": 42,
|
|
39
|
+
"routeCount": 3,
|
|
37
40
|
"createdAt": "2024-01-01T00:00:00Z",
|
|
38
41
|
"updatedAt": "2024-01-01T00:00:00Z",
|
|
39
42
|
}
|
|
@@ -158,7 +161,7 @@ def test_create_source_with_transient_mode(mock_api, client):
|
|
|
158
161
|
|
|
159
162
|
|
|
160
163
|
def test_create_source_params_serialization():
|
|
161
|
-
params = CreateSourceParams(name="test", transient_mode=True)
|
|
164
|
+
params = CreateSourceParams(name="test", slug="test", transient_mode=True)
|
|
162
165
|
dumped = params.model_dump(by_alias=True, exclude_none=True)
|
|
163
166
|
assert dumped["transientMode"] is True
|
|
164
167
|
|
|
@@ -227,7 +230,7 @@ def test_source_allowed_methods_malformed_fails_open():
|
|
|
227
230
|
|
|
228
231
|
|
|
229
232
|
def test_create_source_params_serializes_allowed_methods():
|
|
230
|
-
params = CreateSourceParams(name="s", allowed_methods=["GET", "POST"])
|
|
233
|
+
params = CreateSourceParams(name="s", slug="s", allowed_methods=["GET", "POST"])
|
|
231
234
|
assert params.model_dump(by_alias=True, exclude_none=True)["allowedMethods"] == ["GET", "POST"]
|
|
232
235
|
|
|
233
236
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.8.0"
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from typing import Any, Literal
|
|
5
|
-
|
|
6
|
-
from pydantic import field_validator
|
|
7
|
-
|
|
8
|
-
from ._base import HookbaseModel
|
|
9
|
-
|
|
10
|
-
SourceProvider = Literal[
|
|
11
|
-
"generic", "github", "stripe", "shopify", "slack", "twilio",
|
|
12
|
-
"sendgrid", "mailgun", "paddle", "linear", "svix", "custom",
|
|
13
|
-
]
|
|
14
|
-
DedupStrategy = Literal["none", "header", "payload_hash", "event_id"]
|
|
15
|
-
IpFilterMode = Literal["none", "allowlist", "denylist"]
|
|
16
|
-
# HTTP verbs an ingest endpoint can be restricted to. OPTIONS is excluded: CORS preflight is
|
|
17
|
-
# answered before ingest runs, so it is never gateable.
|
|
18
|
-
IngestMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class Source(HookbaseModel):
|
|
22
|
-
id: str
|
|
23
|
-
organization_id: str | None = None
|
|
24
|
-
name: str
|
|
25
|
-
slug: str
|
|
26
|
-
description: str | None = None
|
|
27
|
-
provider: SourceProvider = "generic"
|
|
28
|
-
is_active: bool = True
|
|
29
|
-
signing_secret: str | None = None
|
|
30
|
-
ingest_url: str | None = None
|
|
31
|
-
verify_signature: bool = False
|
|
32
|
-
dedup_strategy: str | None = "none"
|
|
33
|
-
dedup_window: int | None = None
|
|
34
|
-
dedup_header_name: str | None = None
|
|
35
|
-
ip_filter_mode: IpFilterMode = "none"
|
|
36
|
-
ip_allowlist: list[str] | None = None
|
|
37
|
-
ip_denylist: list[str] | None = None
|
|
38
|
-
rate_limit: int | None = None
|
|
39
|
-
rate_limit_window: int | None = None
|
|
40
|
-
transient_mode: bool = False
|
|
41
|
-
#: HTTP verbs the ingest endpoint accepts. Empty list means any method.
|
|
42
|
-
allowed_methods: list[str] = []
|
|
43
|
-
event_count: int = 0
|
|
44
|
-
last_event_at: str | None = None
|
|
45
|
-
created_at: str = ""
|
|
46
|
-
updated_at: str = ""
|
|
47
|
-
|
|
48
|
-
@field_validator("is_active", "verify_signature", "transient_mode", mode="before")
|
|
49
|
-
@classmethod
|
|
50
|
-
def parse_bool_from_int(cls, v: Any) -> Any:
|
|
51
|
-
if isinstance(v, int):
|
|
52
|
-
return bool(v)
|
|
53
|
-
return v
|
|
54
|
-
|
|
55
|
-
@field_validator("ip_allowlist", "ip_denylist", mode="before")
|
|
56
|
-
@classmethod
|
|
57
|
-
def parse_json_list(cls, v: Any) -> Any:
|
|
58
|
-
if isinstance(v, str):
|
|
59
|
-
try:
|
|
60
|
-
parsed = json.loads(v)
|
|
61
|
-
return parsed if isinstance(parsed, list) else None
|
|
62
|
-
except (json.JSONDecodeError, ValueError):
|
|
63
|
-
return None
|
|
64
|
-
return v
|
|
65
|
-
|
|
66
|
-
@field_validator("allowed_methods", mode="before")
|
|
67
|
-
@classmethod
|
|
68
|
-
def parse_allowed_methods(cls, v: Any) -> Any:
|
|
69
|
-
"""Coerce null/JSON-string forms to a list; both mean "any method" when empty."""
|
|
70
|
-
if v is None:
|
|
71
|
-
return []
|
|
72
|
-
if isinstance(v, str):
|
|
73
|
-
try:
|
|
74
|
-
parsed = json.loads(v)
|
|
75
|
-
return parsed if isinstance(parsed, list) else []
|
|
76
|
-
except (json.JSONDecodeError, ValueError):
|
|
77
|
-
return []
|
|
78
|
-
return v
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
class SourceWithSecret(Source):
|
|
82
|
-
signing_secret: str = ""
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
class CreateSourceParams(HookbaseModel):
|
|
86
|
-
name: str
|
|
87
|
-
slug: str | None = None
|
|
88
|
-
description: str | None = None
|
|
89
|
-
provider: SourceProvider | None = None
|
|
90
|
-
verify_signature: bool | None = None
|
|
91
|
-
dedup_strategy: DedupStrategy | None = None
|
|
92
|
-
dedup_window: int | None = None
|
|
93
|
-
dedup_header_name: str | None = None
|
|
94
|
-
ip_filter_mode: IpFilterMode | None = None
|
|
95
|
-
ip_allowlist: list[str] | None = None
|
|
96
|
-
ip_denylist: list[str] | None = None
|
|
97
|
-
rate_limit: int | None = None
|
|
98
|
-
rate_limit_window: int | None = None
|
|
99
|
-
transient_mode: bool | None = None
|
|
100
|
-
#: Restrict the ingest endpoint to these verbs. None/[] accepts any method.
|
|
101
|
-
allowed_methods: list[IngestMethod] | None = None
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
class UpdateSourceParams(HookbaseModel):
|
|
105
|
-
name: str | None = None
|
|
106
|
-
description: str | None = None
|
|
107
|
-
is_active: bool | None = None
|
|
108
|
-
verify_signature: bool | None = None
|
|
109
|
-
dedup_strategy: DedupStrategy | None = None
|
|
110
|
-
dedup_window: int | None = None
|
|
111
|
-
dedup_header_name: str | None = None
|
|
112
|
-
ip_filter_mode: IpFilterMode | None = None
|
|
113
|
-
ip_allowlist: list[str] | None = None
|
|
114
|
-
ip_denylist: list[str] | None = None
|
|
115
|
-
rate_limit: int | None = None
|
|
116
|
-
rate_limit_window: int | None = None
|
|
117
|
-
transient_mode: bool | None = None
|
|
118
|
-
#: Restrict the ingest endpoint to these verbs. Pass [] to accept any method again.
|
|
119
|
-
allowed_methods: list[IngestMethod] | None = None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|