svc-infra 0.1.595__py3-none-any.whl → 0.1.706__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.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/__init__.py +58 -2
- svc_infra/apf_payments/models.py +133 -42
- svc_infra/apf_payments/provider/aiydan.py +121 -47
- svc_infra/apf_payments/provider/base.py +30 -9
- svc_infra/apf_payments/provider/stripe.py +156 -62
- svc_infra/apf_payments/schemas.py +18 -9
- svc_infra/apf_payments/service.py +98 -41
- svc_infra/apf_payments/settings.py +5 -1
- svc_infra/api/__init__.py +61 -0
- svc_infra/api/fastapi/__init__.py +15 -0
- svc_infra/api/fastapi/admin/__init__.py +3 -0
- svc_infra/api/fastapi/admin/add.py +245 -0
- svc_infra/api/fastapi/apf_payments/router.py +128 -70
- svc_infra/api/fastapi/apf_payments/setup.py +13 -6
- svc_infra/api/fastapi/auth/__init__.py +65 -0
- svc_infra/api/fastapi/auth/_cookies.py +6 -2
- svc_infra/api/fastapi/auth/add.py +17 -14
- svc_infra/api/fastapi/auth/gaurd.py +45 -16
- svc_infra/api/fastapi/auth/mfa/models.py +3 -1
- svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
- svc_infra/api/fastapi/auth/mfa/router.py +15 -8
- svc_infra/api/fastapi/auth/mfa/security.py +1 -2
- svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
- svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
- svc_infra/api/fastapi/auth/policy.py +0 -1
- svc_infra/api/fastapi/auth/providers.py +3 -1
- svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
- svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
- svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
- svc_infra/api/fastapi/auth/security.py +31 -10
- svc_infra/api/fastapi/auth/sender.py +8 -1
- svc_infra/api/fastapi/auth/state.py +3 -1
- svc_infra/api/fastapi/auth/ws_security.py +275 -0
- svc_infra/api/fastapi/billing/router.py +73 -0
- svc_infra/api/fastapi/billing/setup.py +19 -0
- svc_infra/api/fastapi/cache/add.py +9 -5
- svc_infra/api/fastapi/db/__init__.py +5 -1
- svc_infra/api/fastapi/db/http.py +3 -1
- svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
- svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
- svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
- svc_infra/api/fastapi/db/sql/__init__.py +5 -1
- svc_infra/api/fastapi/db/sql/add.py +71 -26
- svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
- svc_infra/api/fastapi/db/sql/health.py +3 -1
- svc_infra/api/fastapi/db/sql/session.py +18 -0
- svc_infra/api/fastapi/db/sql/users.py +18 -6
- svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
- svc_infra/api/fastapi/docs/add.py +173 -0
- svc_infra/api/fastapi/docs/landing.py +4 -2
- svc_infra/api/fastapi/docs/scoped.py +62 -15
- svc_infra/api/fastapi/dual/__init__.py +12 -2
- svc_infra/api/fastapi/dual/dualize.py +1 -1
- svc_infra/api/fastapi/dual/protected.py +126 -4
- svc_infra/api/fastapi/dual/public.py +25 -0
- svc_infra/api/fastapi/dual/router.py +40 -13
- svc_infra/api/fastapi/dx.py +33 -2
- svc_infra/api/fastapi/ease.py +10 -2
- svc_infra/api/fastapi/http/concurrency.py +2 -1
- svc_infra/api/fastapi/http/conditional.py +3 -1
- svc_infra/api/fastapi/middleware/debug.py +4 -1
- svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
- svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
- svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
- svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
- svc_infra/api/fastapi/middleware/idempotency.py +197 -70
- svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
- svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
- svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
- svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
- svc_infra/api/fastapi/middleware/request_id.py +27 -11
- svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
- svc_infra/api/fastapi/middleware/timeout.py +177 -0
- svc_infra/api/fastapi/openapi/apply.py +5 -3
- svc_infra/api/fastapi/openapi/conventions.py +9 -2
- svc_infra/api/fastapi/openapi/mutators.py +165 -20
- svc_infra/api/fastapi/openapi/pipeline.py +1 -1
- svc_infra/api/fastapi/openapi/security.py +3 -1
- svc_infra/api/fastapi/ops/add.py +75 -0
- svc_infra/api/fastapi/pagination.py +47 -20
- svc_infra/api/fastapi/routers/__init__.py +43 -15
- svc_infra/api/fastapi/routers/ping.py +1 -0
- svc_infra/api/fastapi/setup.py +188 -57
- svc_infra/api/fastapi/tenancy/add.py +19 -0
- svc_infra/api/fastapi/tenancy/context.py +112 -0
- svc_infra/api/fastapi/versioned.py +101 -0
- svc_infra/app/README.md +5 -5
- svc_infra/app/__init__.py +3 -1
- svc_infra/app/env.py +69 -1
- svc_infra/app/logging/add.py +9 -2
- svc_infra/app/logging/formats.py +12 -5
- svc_infra/billing/__init__.py +23 -0
- svc_infra/billing/async_service.py +147 -0
- svc_infra/billing/jobs.py +241 -0
- svc_infra/billing/models.py +177 -0
- svc_infra/billing/quotas.py +103 -0
- svc_infra/billing/schemas.py +36 -0
- svc_infra/billing/service.py +123 -0
- svc_infra/bundled_docs/README.md +5 -0
- svc_infra/bundled_docs/__init__.py +1 -0
- svc_infra/bundled_docs/getting-started.md +6 -0
- svc_infra/cache/__init__.py +9 -0
- svc_infra/cache/add.py +170 -0
- svc_infra/cache/backend.py +7 -6
- svc_infra/cache/decorators.py +81 -15
- svc_infra/cache/demo.py +2 -2
- svc_infra/cache/keys.py +24 -4
- svc_infra/cache/recache.py +26 -14
- svc_infra/cache/resources.py +14 -5
- svc_infra/cache/tags.py +19 -44
- svc_infra/cache/utils.py +3 -1
- svc_infra/cli/__init__.py +52 -8
- svc_infra/cli/__main__.py +4 -0
- svc_infra/cli/cmds/__init__.py +39 -2
- svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
- svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
- svc_infra/cli/cmds/db/ops_cmds.py +270 -0
- svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
- svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
- svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
- svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
- svc_infra/cli/cmds/dx/__init__.py +12 -0
- svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
- svc_infra/cli/cmds/health/__init__.py +179 -0
- svc_infra/cli/cmds/health/health_cmds.py +8 -0
- svc_infra/cli/cmds/help.py +4 -0
- svc_infra/cli/cmds/jobs/__init__.py +1 -0
- svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
- svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
- svc_infra/cli/cmds/sdk/__init__.py +0 -0
- svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
- svc_infra/cli/foundation/runner.py +6 -2
- svc_infra/data/add.py +61 -0
- svc_infra/data/backup.py +58 -0
- svc_infra/data/erasure.py +45 -0
- svc_infra/data/fixtures.py +42 -0
- svc_infra/data/retention.py +61 -0
- svc_infra/db/__init__.py +15 -0
- svc_infra/db/crud_schema.py +9 -9
- svc_infra/db/inbox.py +67 -0
- svc_infra/db/nosql/__init__.py +3 -0
- svc_infra/db/nosql/core.py +30 -9
- svc_infra/db/nosql/indexes.py +3 -1
- svc_infra/db/nosql/management.py +1 -1
- svc_infra/db/nosql/mongo/README.md +13 -13
- svc_infra/db/nosql/mongo/client.py +19 -2
- svc_infra/db/nosql/mongo/settings.py +6 -2
- svc_infra/db/nosql/repository.py +35 -15
- svc_infra/db/nosql/resource.py +20 -3
- svc_infra/db/nosql/scaffold.py +9 -3
- svc_infra/db/nosql/service.py +3 -1
- svc_infra/db/nosql/types.py +6 -2
- svc_infra/db/ops.py +384 -0
- svc_infra/db/outbox.py +108 -0
- svc_infra/db/sql/apikey.py +37 -9
- svc_infra/db/sql/authref.py +9 -3
- svc_infra/db/sql/constants.py +12 -8
- svc_infra/db/sql/core.py +2 -2
- svc_infra/db/sql/management.py +11 -8
- svc_infra/db/sql/repository.py +99 -26
- svc_infra/db/sql/resource.py +5 -0
- svc_infra/db/sql/scaffold.py +6 -2
- svc_infra/db/sql/service.py +15 -5
- svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
- svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
- svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
- svc_infra/db/sql/tenant.py +88 -0
- svc_infra/db/sql/uniq_hooks.py +9 -3
- svc_infra/db/sql/utils.py +138 -51
- svc_infra/db/sql/versioning.py +14 -0
- svc_infra/deploy/__init__.py +538 -0
- svc_infra/documents/__init__.py +100 -0
- svc_infra/documents/add.py +264 -0
- svc_infra/documents/ease.py +233 -0
- svc_infra/documents/models.py +114 -0
- svc_infra/documents/storage.py +264 -0
- svc_infra/dx/add.py +65 -0
- svc_infra/dx/changelog.py +74 -0
- svc_infra/dx/checks.py +68 -0
- svc_infra/exceptions.py +141 -0
- svc_infra/health/__init__.py +864 -0
- svc_infra/http/__init__.py +13 -0
- svc_infra/http/client.py +105 -0
- svc_infra/jobs/builtins/outbox_processor.py +40 -0
- svc_infra/jobs/builtins/webhook_delivery.py +95 -0
- svc_infra/jobs/easy.py +33 -0
- svc_infra/jobs/loader.py +50 -0
- svc_infra/jobs/queue.py +116 -0
- svc_infra/jobs/redis_queue.py +256 -0
- svc_infra/jobs/runner.py +79 -0
- svc_infra/jobs/scheduler.py +53 -0
- svc_infra/jobs/worker.py +40 -0
- svc_infra/loaders/__init__.py +186 -0
- svc_infra/loaders/base.py +142 -0
- svc_infra/loaders/github.py +311 -0
- svc_infra/loaders/models.py +147 -0
- svc_infra/loaders/url.py +235 -0
- svc_infra/logging/__init__.py +374 -0
- svc_infra/mcp/svc_infra_mcp.py +91 -33
- svc_infra/obs/README.md +2 -0
- svc_infra/obs/add.py +65 -9
- svc_infra/obs/cloud_dash.py +2 -1
- svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
- svc_infra/obs/metrics/__init__.py +3 -4
- svc_infra/obs/metrics/asgi.py +13 -7
- svc_infra/obs/metrics/http.py +9 -5
- svc_infra/obs/metrics/sqlalchemy.py +13 -9
- svc_infra/obs/metrics.py +6 -5
- svc_infra/obs/settings.py +6 -2
- svc_infra/security/add.py +217 -0
- svc_infra/security/audit.py +92 -10
- svc_infra/security/audit_service.py +4 -3
- svc_infra/security/headers.py +15 -2
- svc_infra/security/hibp.py +14 -4
- svc_infra/security/jwt_rotation.py +74 -22
- svc_infra/security/lockout.py +11 -5
- svc_infra/security/models.py +54 -12
- svc_infra/security/oauth_models.py +73 -0
- svc_infra/security/org_invites.py +5 -3
- svc_infra/security/passwords.py +3 -1
- svc_infra/security/permissions.py +25 -2
- svc_infra/security/session.py +1 -1
- svc_infra/security/signed_cookies.py +21 -1
- svc_infra/storage/__init__.py +93 -0
- svc_infra/storage/add.py +253 -0
- svc_infra/storage/backends/__init__.py +11 -0
- svc_infra/storage/backends/local.py +339 -0
- svc_infra/storage/backends/memory.py +216 -0
- svc_infra/storage/backends/s3.py +353 -0
- svc_infra/storage/base.py +239 -0
- svc_infra/storage/easy.py +185 -0
- svc_infra/storage/settings.py +195 -0
- svc_infra/testing/__init__.py +685 -0
- svc_infra/utils.py +7 -3
- svc_infra/webhooks/__init__.py +69 -0
- svc_infra/webhooks/add.py +339 -0
- svc_infra/webhooks/encryption.py +115 -0
- svc_infra/webhooks/fastapi.py +39 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +70 -0
- svc_infra/webhooks/signing.py +34 -0
- svc_infra/websocket/__init__.py +79 -0
- svc_infra/websocket/add.py +140 -0
- svc_infra/websocket/client.py +282 -0
- svc_infra/websocket/config.py +69 -0
- svc_infra/websocket/easy.py +76 -0
- svc_infra/websocket/exceptions.py +61 -0
- svc_infra/websocket/manager.py +344 -0
- svc_infra/websocket/models.py +49 -0
- svc_infra-0.1.706.dist-info/LICENSE +21 -0
- svc_infra-0.1.706.dist-info/METADATA +356 -0
- svc_infra-0.1.706.dist-info/RECORD +357 -0
- svc_infra-0.1.595.dist-info/METADATA +0 -80
- svc_infra-0.1.595.dist-info/RECORD +0 -253
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from .add import add_webhooks
|
|
7
|
+
from .encryption import decrypt_secret, encrypt_secret, is_encrypted
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"add_webhooks",
|
|
11
|
+
"encrypt_secret",
|
|
12
|
+
"decrypt_secret",
|
|
13
|
+
"is_encrypted",
|
|
14
|
+
"trigger_webhook",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
_logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def trigger_webhook(
|
|
21
|
+
event: str,
|
|
22
|
+
data: dict[str, Any],
|
|
23
|
+
*,
|
|
24
|
+
webhook_service: Any | None = None,
|
|
25
|
+
) -> int | None:
|
|
26
|
+
"""
|
|
27
|
+
Trigger a webhook event.
|
|
28
|
+
|
|
29
|
+
This is a convenience function for sending webhook events. It requires
|
|
30
|
+
that webhooks have been configured via add_webhooks() first.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
event: The event/topic name (e.g., "goal.milestone_reached")
|
|
34
|
+
data: The event payload data
|
|
35
|
+
webhook_service: Optional WebhookService instance. If not provided,
|
|
36
|
+
attempts to use the global service from add_webhooks.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The outbox message ID if successful, None if no webhook service configured.
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
from svc_infra.webhooks import trigger_webhook
|
|
43
|
+
|
|
44
|
+
await trigger_webhook(
|
|
45
|
+
event="user.created",
|
|
46
|
+
data={"user_id": "123", "email": "user@example.com"}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
Note:
|
|
50
|
+
For this to work, you must first configure webhooks:
|
|
51
|
+
|
|
52
|
+
from svc_infra.webhooks import add_webhooks
|
|
53
|
+
add_webhooks(app)
|
|
54
|
+
"""
|
|
55
|
+
if webhook_service is None:
|
|
56
|
+
# Try to get the global webhook service from app state
|
|
57
|
+
_logger.warning(
|
|
58
|
+
"No webhook_service provided and no global service configured. "
|
|
59
|
+
"Call add_webhooks(app) first to enable webhook delivery."
|
|
60
|
+
)
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
msg_id = cast(int, webhook_service.publish(event, data))
|
|
65
|
+
_logger.info(f"Triggered webhook event '{event}' with message ID {msg_id}")
|
|
66
|
+
return msg_id
|
|
67
|
+
except Exception as e:
|
|
68
|
+
_logger.error(f"Failed to trigger webhook event '{event}': {e}")
|
|
69
|
+
return None
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""FastAPI integration helpers for the webhooks router.
|
|
2
|
+
|
|
3
|
+
The :func:`add_webhooks` helper wires the public router into an app and makes
|
|
4
|
+
sure dependency overrides share a single set of stores instead of the in-file
|
|
5
|
+
defaults that create a new in-memory object per request. Callers can:
|
|
6
|
+
|
|
7
|
+
* rely on the in-memory defaults (suitable for tests / local usage);
|
|
8
|
+
* configure persistent stores through environment variables; or
|
|
9
|
+
* provide concrete instances / factories explicitly via keyword arguments.
|
|
10
|
+
|
|
11
|
+
When queue / scheduler objects are provided the helper also wires up the
|
|
12
|
+
standard outbox tick task and webhook delivery job handler so the caller only
|
|
13
|
+
needs to start their existing worker loop.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from typing import Any, Protocol, TypeGuard, TypeVar, cast
|
|
24
|
+
|
|
25
|
+
from fastapi import FastAPI
|
|
26
|
+
|
|
27
|
+
from svc_infra.db.inbox import InboxStore, InMemoryInboxStore
|
|
28
|
+
from svc_infra.db.outbox import InMemoryOutboxStore, OutboxMessage, OutboxStore
|
|
29
|
+
from svc_infra.jobs.builtins.outbox_processor import make_outbox_tick
|
|
30
|
+
from svc_infra.jobs.builtins.webhook_delivery import make_webhook_handler
|
|
31
|
+
from svc_infra.jobs.queue import JobQueue
|
|
32
|
+
from svc_infra.jobs.scheduler import InMemoryScheduler
|
|
33
|
+
|
|
34
|
+
from . import router as router_module
|
|
35
|
+
from .service import InMemoryWebhookSubscriptions
|
|
36
|
+
|
|
37
|
+
try: # Optional dependency – only required when redis backends are selected.
|
|
38
|
+
from redis import Redis
|
|
39
|
+
except Exception: # pragma: no cover - redis is optional in most test runs.
|
|
40
|
+
Redis = None # type: ignore[misc,assignment]
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
T_co = TypeVar("T_co", covariant=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _Factory(Protocol[T_co]):
|
|
49
|
+
def __call__(self) -> T_co:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RedisOutboxStore(OutboxStore):
|
|
54
|
+
"""Minimal Redis-backed outbox implementation used by :func:`add_webhooks`.
|
|
55
|
+
|
|
56
|
+
The implementation is intentionally lightweight – it keeps message payloads
|
|
57
|
+
in Redis hashes and a FIFO list of message identifiers. It fulfils the
|
|
58
|
+
contract expected by :func:`make_outbox_tick` while remaining simple enough
|
|
59
|
+
for environments where a fully fledged SQL implementation is unavailable.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, client: "Redis", *, prefix: str = "webhooks:outbox"):
|
|
63
|
+
if Redis is None: # pragma: no cover - defensive guard
|
|
64
|
+
raise RuntimeError("redis-py is required for RedisOutboxStore")
|
|
65
|
+
self._client = client
|
|
66
|
+
self._prefix = prefix.rstrip(":")
|
|
67
|
+
|
|
68
|
+
# Redis key helpers -------------------------------------------------
|
|
69
|
+
@property
|
|
70
|
+
def _seq_key(self) -> str:
|
|
71
|
+
return f"{self._prefix}:seq"
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def _queue_key(self) -> str:
|
|
75
|
+
return f"{self._prefix}:queue"
|
|
76
|
+
|
|
77
|
+
def _msg_key(self, msg_id: int) -> str:
|
|
78
|
+
return f"{self._prefix}:msg:{msg_id}"
|
|
79
|
+
|
|
80
|
+
# Protocol methods --------------------------------------------------
|
|
81
|
+
def enqueue(self, topic: str, payload: dict[str, Any]) -> OutboxMessage:
|
|
82
|
+
incr_result = cast(Any, self._client.incr(self._seq_key))
|
|
83
|
+
# Redis incr always returns an int for the sync client. Be defensive for mocks.
|
|
84
|
+
try:
|
|
85
|
+
msg_id = int(incr_result)
|
|
86
|
+
except (TypeError, ValueError):
|
|
87
|
+
msg_id = 0
|
|
88
|
+
created_at = datetime.now(timezone.utc)
|
|
89
|
+
record: dict[str, str] = {
|
|
90
|
+
"id": str(msg_id),
|
|
91
|
+
"topic": topic,
|
|
92
|
+
"payload": json.dumps(payload),
|
|
93
|
+
"created_at": created_at.isoformat(),
|
|
94
|
+
"attempts": "0",
|
|
95
|
+
"processed_at": "",
|
|
96
|
+
}
|
|
97
|
+
self._client.hset(self._msg_key(msg_id), mapping=record)
|
|
98
|
+
self._client.rpush(self._queue_key, msg_id)
|
|
99
|
+
return OutboxMessage(
|
|
100
|
+
id=msg_id, topic=topic, payload=payload, created_at=created_at
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def fetch_next(self, topics: Iterable[str] | None = None) -> OutboxMessage | None:
|
|
104
|
+
allowed = set(topics) if topics else None
|
|
105
|
+
ids = cast(list[Any], self._client.lrange(self._queue_key, 0, -1))
|
|
106
|
+
for raw_id in ids:
|
|
107
|
+
raw_id_str = (
|
|
108
|
+
raw_id.decode()
|
|
109
|
+
if isinstance(raw_id, (bytes, bytearray))
|
|
110
|
+
else str(raw_id)
|
|
111
|
+
)
|
|
112
|
+
msg_id = int(raw_id_str)
|
|
113
|
+
msg = cast(dict[Any, Any], self._client.hgetall(self._msg_key(msg_id)))
|
|
114
|
+
if not msg:
|
|
115
|
+
continue
|
|
116
|
+
topic = msg.get(b"topic")
|
|
117
|
+
if topic is None:
|
|
118
|
+
continue
|
|
119
|
+
topic_str = (
|
|
120
|
+
topic.decode() if isinstance(topic, (bytes, bytearray)) else str(topic)
|
|
121
|
+
)
|
|
122
|
+
if allowed is not None and topic_str not in allowed:
|
|
123
|
+
continue
|
|
124
|
+
attempts = int(msg.get(b"attempts", 0))
|
|
125
|
+
processed_raw = msg.get(b"processed_at") or b""
|
|
126
|
+
if processed_raw:
|
|
127
|
+
continue
|
|
128
|
+
if attempts > 0:
|
|
129
|
+
continue
|
|
130
|
+
payload_raw = msg.get(b"payload") or b"{}"
|
|
131
|
+
payload_txt = (
|
|
132
|
+
payload_raw.decode()
|
|
133
|
+
if isinstance(payload_raw, (bytes, bytearray))
|
|
134
|
+
else str(payload_raw)
|
|
135
|
+
)
|
|
136
|
+
payload = json.loads(payload_txt)
|
|
137
|
+
created_raw = msg.get(b"created_at") or b""
|
|
138
|
+
created_at = (
|
|
139
|
+
datetime.fromisoformat(
|
|
140
|
+
created_raw.decode()
|
|
141
|
+
if isinstance(created_raw, (bytes, bytearray))
|
|
142
|
+
else str(created_raw)
|
|
143
|
+
)
|
|
144
|
+
if created_raw
|
|
145
|
+
else datetime.now(timezone.utc)
|
|
146
|
+
)
|
|
147
|
+
return OutboxMessage(
|
|
148
|
+
id=msg_id,
|
|
149
|
+
topic=topic_str,
|
|
150
|
+
payload=payload,
|
|
151
|
+
created_at=created_at,
|
|
152
|
+
attempts=attempts,
|
|
153
|
+
)
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
def mark_processed(self, msg_id: int) -> None:
|
|
157
|
+
key = self._msg_key(msg_id)
|
|
158
|
+
if not self._client.exists(key):
|
|
159
|
+
return
|
|
160
|
+
self._client.hset(key, "processed_at", datetime.now(timezone.utc).isoformat())
|
|
161
|
+
|
|
162
|
+
def mark_failed(self, msg_id: int) -> None:
|
|
163
|
+
key = self._msg_key(msg_id)
|
|
164
|
+
self._client.hincrby(key, "attempts", 1)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class RedisInboxStore(InboxStore):
|
|
168
|
+
"""Lightweight Redis dedupe store for webhook deliveries."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, client: "Redis", *, prefix: str = "webhooks:inbox"):
|
|
171
|
+
if Redis is None: # pragma: no cover - defensive guard
|
|
172
|
+
raise RuntimeError("redis-py is required for RedisInboxStore")
|
|
173
|
+
self._client = client
|
|
174
|
+
self._prefix = prefix.rstrip(":")
|
|
175
|
+
|
|
176
|
+
def _key(self, key: str) -> str:
|
|
177
|
+
return f"{self._prefix}:{key}"
|
|
178
|
+
|
|
179
|
+
def mark_if_new(self, key: str, ttl_seconds: int = 24 * 3600) -> bool:
|
|
180
|
+
return bool(self._client.set(self._key(key), 1, nx=True, ex=ttl_seconds))
|
|
181
|
+
|
|
182
|
+
def purge_expired(self) -> int:
|
|
183
|
+
# Redis takes care of expirations. We return 0 to satisfy the interface.
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
def is_marked(self, key: str) -> bool:
|
|
187
|
+
return bool(self._client.exists(self._key(key)))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _is_factory(obj: Any) -> TypeGuard[Callable[[], Any]]:
|
|
191
|
+
return callable(obj) and not isinstance(obj, (str, bytes, bytearray))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _resolve_value(
|
|
195
|
+
value: T_co | _Factory[T_co] | None, default_factory: _Factory[T_co]
|
|
196
|
+
) -> T_co:
|
|
197
|
+
if value is None:
|
|
198
|
+
return default_factory()
|
|
199
|
+
if _is_factory(value):
|
|
200
|
+
return cast(T_co, value())
|
|
201
|
+
return cast(T_co, value)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _build_redis_client(env: Mapping[str, str]) -> "Redis" | None:
|
|
205
|
+
if Redis is None:
|
|
206
|
+
logger.warning(
|
|
207
|
+
"Redis backend requested but redis-py is not installed; falling back to in-memory stores"
|
|
208
|
+
)
|
|
209
|
+
return None
|
|
210
|
+
url = env.get("REDIS_URL", "redis://localhost:6379/0")
|
|
211
|
+
return Redis.from_url(url)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _default_outbox(env: Mapping[str, str]) -> OutboxStore:
|
|
215
|
+
backend = (env.get("WEBHOOKS_OUTBOX") or "memory").lower()
|
|
216
|
+
if backend == "redis":
|
|
217
|
+
client = _build_redis_client(env)
|
|
218
|
+
if client is not None:
|
|
219
|
+
logger.info("Using Redis outbox store for webhooks")
|
|
220
|
+
return RedisOutboxStore(client)
|
|
221
|
+
elif backend == "sql": # pragma: no cover - SQL backend is currently a placeholder
|
|
222
|
+
logger.warning(
|
|
223
|
+
"WEBHOOKS_OUTBOX=sql specified but SQL backend is not implemented; falling back to in-memory store"
|
|
224
|
+
)
|
|
225
|
+
return InMemoryOutboxStore()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _default_inbox(env: Mapping[str, str]) -> InboxStore:
|
|
229
|
+
backend = (env.get("WEBHOOKS_INBOX") or "memory").lower()
|
|
230
|
+
if backend == "redis":
|
|
231
|
+
client = _build_redis_client(env)
|
|
232
|
+
if client is not None:
|
|
233
|
+
logger.info("Using Redis inbox store for webhooks")
|
|
234
|
+
return RedisInboxStore(client)
|
|
235
|
+
return InMemoryInboxStore()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _default_subscriptions() -> InMemoryWebhookSubscriptions:
|
|
239
|
+
return InMemoryWebhookSubscriptions()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _subscription_lookup(
|
|
243
|
+
subs: InMemoryWebhookSubscriptions,
|
|
244
|
+
) -> tuple[Callable[[str], str], Callable[[str], str]]:
|
|
245
|
+
def _get_url(topic: str) -> str:
|
|
246
|
+
items = subs.get_for_topic(topic)
|
|
247
|
+
if not items:
|
|
248
|
+
raise LookupError(f"No webhook subscription for topic '{topic}'")
|
|
249
|
+
return items[0].url
|
|
250
|
+
|
|
251
|
+
def _get_secret(topic: str) -> str:
|
|
252
|
+
items = subs.get_for_topic(topic)
|
|
253
|
+
if not items:
|
|
254
|
+
raise LookupError(f"No webhook subscription for topic '{topic}'")
|
|
255
|
+
return items[0].secret
|
|
256
|
+
|
|
257
|
+
return _get_url, _get_secret
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def add_webhooks(
|
|
261
|
+
app: FastAPI,
|
|
262
|
+
*,
|
|
263
|
+
outbox: OutboxStore | _Factory[OutboxStore] | None = None,
|
|
264
|
+
inbox: InboxStore | _Factory[InboxStore] | None = None,
|
|
265
|
+
subscriptions: (
|
|
266
|
+
InMemoryWebhookSubscriptions | _Factory[InMemoryWebhookSubscriptions] | None
|
|
267
|
+
) = None,
|
|
268
|
+
queue: JobQueue | None = None,
|
|
269
|
+
scheduler: InMemoryScheduler | None = None,
|
|
270
|
+
schedule_tick: bool = True,
|
|
271
|
+
env: Mapping[str, str] = os.environ,
|
|
272
|
+
) -> None:
|
|
273
|
+
"""Attach the shared webhooks router and stores to a FastAPI app.
|
|
274
|
+
|
|
275
|
+
Parameters
|
|
276
|
+
----------
|
|
277
|
+
app:
|
|
278
|
+
The FastAPI application to configure.
|
|
279
|
+
outbox / inbox / subscriptions:
|
|
280
|
+
Optional instances or callables returning instances to use. When left
|
|
281
|
+
as ``None`` the helper chooses sensible defaults: in-memory stores for
|
|
282
|
+
local runs or Redis-backed stores when ``WEBHOOKS_OUTBOX`` /
|
|
283
|
+
``WEBHOOKS_INBOX`` are set to ``"redis"`` and ``REDIS_URL`` is
|
|
284
|
+
available.
|
|
285
|
+
queue / scheduler:
|
|
286
|
+
Provide these when you want :func:`make_outbox_tick` and the webhook
|
|
287
|
+
delivery handler registered for you. The tick task is scheduled every
|
|
288
|
+
second by default; disable that registration by passing
|
|
289
|
+
``schedule_tick=False``.
|
|
290
|
+
env:
|
|
291
|
+
Mapping used to resolve environment-driven defaults. Defaults to
|
|
292
|
+
:data:`os.environ` so standard environment variables Just Work.
|
|
293
|
+
|
|
294
|
+
Side effects
|
|
295
|
+
------------
|
|
296
|
+
* ``app.include_router`` is invoked for :mod:`svc_infra.webhooks.router`.
|
|
297
|
+
* ``app.dependency_overrides`` is populated so router dependencies reuse the
|
|
298
|
+
shared stores.
|
|
299
|
+
* References are stored on ``app.state`` for further customisation:
|
|
300
|
+
``webhooks_outbox``, ``webhooks_inbox``, ``webhooks_subscriptions``,
|
|
301
|
+
``webhooks_outbox_tick`` (when a queue is present) and
|
|
302
|
+
``webhooks_delivery_handler`` (when queue+inbox are present).
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
resolved_outbox = _resolve_value(outbox, lambda: _default_outbox(env))
|
|
306
|
+
resolved_inbox = _resolve_value(inbox, lambda: _default_inbox(env))
|
|
307
|
+
resolved_subs = _resolve_value(subscriptions, _default_subscriptions)
|
|
308
|
+
|
|
309
|
+
app.state.webhooks_outbox = resolved_outbox
|
|
310
|
+
app.state.webhooks_inbox = resolved_inbox
|
|
311
|
+
app.state.webhooks_subscriptions = resolved_subs
|
|
312
|
+
|
|
313
|
+
app.include_router(router_module.router)
|
|
314
|
+
|
|
315
|
+
app.dependency_overrides[router_module.get_outbox] = lambda: resolved_outbox
|
|
316
|
+
app.dependency_overrides[router_module.get_subs] = lambda: resolved_subs
|
|
317
|
+
|
|
318
|
+
outbox_tick = None
|
|
319
|
+
if queue is not None:
|
|
320
|
+
outbox_tick = make_outbox_tick(resolved_outbox, queue)
|
|
321
|
+
app.state.webhooks_outbox_tick = outbox_tick
|
|
322
|
+
if scheduler is not None and schedule_tick:
|
|
323
|
+
scheduler.add_task("webhooks.outbox", 1, outbox_tick)
|
|
324
|
+
|
|
325
|
+
url_lookup, secret_lookup = _subscription_lookup(resolved_subs)
|
|
326
|
+
handler = make_webhook_handler(
|
|
327
|
+
outbox=resolved_outbox,
|
|
328
|
+
inbox=resolved_inbox,
|
|
329
|
+
get_webhook_url_for_topic=url_lookup,
|
|
330
|
+
get_secret_for_topic=secret_lookup,
|
|
331
|
+
)
|
|
332
|
+
app.state.webhooks_delivery_handler = handler
|
|
333
|
+
elif scheduler is not None and schedule_tick:
|
|
334
|
+
logger.warning(
|
|
335
|
+
"Scheduler provided without queue; skipping outbox tick registration"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
__all__ = ["add_webhooks"]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Encryption utilities for webhook secrets.
|
|
2
|
+
|
|
3
|
+
Provides symmetric encryption for webhook secrets stored in the outbox.
|
|
4
|
+
Uses Fernet (AES-128-CBC with HMAC-SHA256) for authenticated encryption.
|
|
5
|
+
|
|
6
|
+
The encryption key is derived from WEBHOOK_ENCRYPTION_KEY environment variable.
|
|
7
|
+
In production, this MUST be set to a securely generated 32-byte base64 key.
|
|
8
|
+
|
|
9
|
+
Generate a key:
|
|
10
|
+
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
import os
|
|
18
|
+
from functools import lru_cache
|
|
19
|
+
from typing import cast
|
|
20
|
+
|
|
21
|
+
from svc_infra.app.env import require_secret
|
|
22
|
+
|
|
23
|
+
# Marker prefix for encrypted values
|
|
24
|
+
_ENCRYPTED_PREFIX = "enc:v1:"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_encryption_key() -> bytes:
|
|
28
|
+
"""Get the webhook encryption key, requiring it in production."""
|
|
29
|
+
key_str = require_secret(
|
|
30
|
+
os.getenv("WEBHOOK_ENCRYPTION_KEY"),
|
|
31
|
+
"WEBHOOK_ENCRYPTION_KEY",
|
|
32
|
+
dev_default="dev-only-webhook-encryption-key-not-for-production",
|
|
33
|
+
)
|
|
34
|
+
# If it's a Fernet key (44 chars base64), use it directly
|
|
35
|
+
# Otherwise derive a key from it using SHA256
|
|
36
|
+
if len(key_str) == 44 and key_str.endswith("="):
|
|
37
|
+
return base64.urlsafe_b64decode(key_str)
|
|
38
|
+
# Derive a 32-byte key from arbitrary string
|
|
39
|
+
return hashlib.sha256(key_str.encode()).digest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@lru_cache(maxsize=1)
|
|
43
|
+
def _get_fernet():
|
|
44
|
+
"""Get or create the Fernet cipher for encryption/decryption."""
|
|
45
|
+
try:
|
|
46
|
+
from cryptography.fernet import Fernet
|
|
47
|
+
except ImportError:
|
|
48
|
+
# If cryptography is not installed, fall back to no encryption
|
|
49
|
+
# but log a warning
|
|
50
|
+
import logging
|
|
51
|
+
|
|
52
|
+
logging.getLogger(__name__).warning(
|
|
53
|
+
"cryptography package not installed - webhook secrets will NOT be encrypted. "
|
|
54
|
+
"Install with: pip install cryptography"
|
|
55
|
+
)
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
key = _get_encryption_key()
|
|
59
|
+
# Fernet requires a 32-byte key encoded as base64
|
|
60
|
+
fernet_key = base64.urlsafe_b64encode(key)
|
|
61
|
+
return Fernet(fernet_key)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def encrypt_secret(plaintext: str) -> str:
|
|
65
|
+
"""Encrypt a webhook secret for storage.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
plaintext: The secret to encrypt
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Encrypted string with "enc:v1:" prefix, or original if encryption unavailable
|
|
72
|
+
"""
|
|
73
|
+
fernet = _get_fernet()
|
|
74
|
+
if fernet is None:
|
|
75
|
+
return plaintext
|
|
76
|
+
|
|
77
|
+
encrypted = fernet.encrypt(plaintext.encode())
|
|
78
|
+
return _ENCRYPTED_PREFIX + cast(str, encrypted.decode())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def decrypt_secret(ciphertext: str) -> str:
|
|
82
|
+
"""Decrypt a webhook secret from storage.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
ciphertext: The encrypted secret (with "enc:v1:" prefix)
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Decrypted plaintext secret
|
|
89
|
+
|
|
90
|
+
Note:
|
|
91
|
+
If the value doesn't have the encryption prefix, it's returned as-is
|
|
92
|
+
for backwards compatibility with existing unencrypted secrets.
|
|
93
|
+
"""
|
|
94
|
+
# If not encrypted, return as-is (backwards compatibility)
|
|
95
|
+
if not ciphertext.startswith(_ENCRYPTED_PREFIX):
|
|
96
|
+
return ciphertext
|
|
97
|
+
|
|
98
|
+
fernet = _get_fernet()
|
|
99
|
+
if fernet is None:
|
|
100
|
+
# Can't decrypt without cryptography - return as-is
|
|
101
|
+
# This shouldn't happen in practice if encrypt_secret was used
|
|
102
|
+
import logging
|
|
103
|
+
|
|
104
|
+
logging.getLogger(__name__).error(
|
|
105
|
+
"Cannot decrypt webhook secret - cryptography package not installed"
|
|
106
|
+
)
|
|
107
|
+
return ciphertext
|
|
108
|
+
|
|
109
|
+
encrypted = ciphertext[len(_ENCRYPTED_PREFIX) :].encode()
|
|
110
|
+
return cast(str, fernet.decrypt(encrypted).decode())
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_encrypted(value: str) -> bool:
|
|
114
|
+
"""Check if a value is encrypted."""
|
|
115
|
+
return value.startswith(_ENCRYPTED_PREFIX)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Sequence
|
|
4
|
+
|
|
5
|
+
from fastapi import HTTPException, Request, status
|
|
6
|
+
|
|
7
|
+
from .signing import verify, verify_any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def require_signature(
|
|
11
|
+
secrets_provider: Callable[[], str | Sequence[str]],
|
|
12
|
+
*,
|
|
13
|
+
header_name: str = "X-Signature",
|
|
14
|
+
):
|
|
15
|
+
async def _dep(request: Request):
|
|
16
|
+
sig = request.headers.get(header_name)
|
|
17
|
+
if not sig:
|
|
18
|
+
raise HTTPException(
|
|
19
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="missing signature"
|
|
20
|
+
)
|
|
21
|
+
try:
|
|
22
|
+
body = await request.json()
|
|
23
|
+
except Exception:
|
|
24
|
+
raise HTTPException(
|
|
25
|
+
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid JSON body"
|
|
26
|
+
)
|
|
27
|
+
secrets = secrets_provider()
|
|
28
|
+
ok = False
|
|
29
|
+
if isinstance(secrets, str):
|
|
30
|
+
ok = verify(secrets, body, sig)
|
|
31
|
+
else:
|
|
32
|
+
ok = verify_any(secrets, body, sig)
|
|
33
|
+
if not ok:
|
|
34
|
+
raise HTTPException(
|
|
35
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid signature"
|
|
36
|
+
)
|
|
37
|
+
return body
|
|
38
|
+
|
|
39
|
+
return _dep
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
6
|
+
|
|
7
|
+
from svc_infra.db.outbox import InMemoryOutboxStore, OutboxStore
|
|
8
|
+
|
|
9
|
+
from .service import InMemoryWebhookSubscriptions, WebhookService
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/_webhooks", tags=["webhooks"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_outbox() -> OutboxStore:
|
|
15
|
+
# For now expose an in-memory default. Apps can override via DI.
|
|
16
|
+
# In production, provide a proper store through dependency override.
|
|
17
|
+
return InMemoryOutboxStore()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_subs() -> InMemoryWebhookSubscriptions:
|
|
21
|
+
return InMemoryWebhookSubscriptions()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_service(
|
|
25
|
+
outbox: OutboxStore = Depends(get_outbox),
|
|
26
|
+
subs: InMemoryWebhookSubscriptions = Depends(get_subs),
|
|
27
|
+
) -> WebhookService:
|
|
28
|
+
return WebhookService(outbox=outbox, subs=subs)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.post("/subscriptions")
|
|
32
|
+
def add_subscription(
|
|
33
|
+
body: Dict[str, Any],
|
|
34
|
+
subs: InMemoryWebhookSubscriptions = Depends(get_subs),
|
|
35
|
+
):
|
|
36
|
+
topic = body.get("topic")
|
|
37
|
+
url = body.get("url")
|
|
38
|
+
secret = body.get("secret")
|
|
39
|
+
if not topic or not url or not secret:
|
|
40
|
+
raise HTTPException(status_code=400, detail="Missing topic/url/secret")
|
|
41
|
+
subs.add(topic, url, secret)
|
|
42
|
+
return {"ok": True}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.post("/test-fire")
|
|
46
|
+
def test_fire(
|
|
47
|
+
body: Dict[str, Any],
|
|
48
|
+
svc: WebhookService = Depends(get_service),
|
|
49
|
+
):
|
|
50
|
+
topic = body.get("topic")
|
|
51
|
+
payload = body.get("payload") or {}
|
|
52
|
+
if not topic:
|
|
53
|
+
raise HTTPException(status_code=400, detail="Missing topic")
|
|
54
|
+
outbox_id = svc.publish(topic, payload)
|
|
55
|
+
return {"ok": True, "outbox_id": outbox_id}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Dict, List
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from svc_infra.db.outbox import OutboxStore
|
|
9
|
+
from svc_infra.webhooks.encryption import encrypt_secret
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class WebhookSubscription:
|
|
14
|
+
topic: str
|
|
15
|
+
url: str
|
|
16
|
+
secret: str
|
|
17
|
+
id: str = field(default_factory=lambda: uuid4().hex)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InMemoryWebhookSubscriptions:
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self._subs: Dict[str, List[WebhookSubscription]] = {}
|
|
23
|
+
|
|
24
|
+
def add(self, topic: str, url: str, secret: str) -> None:
|
|
25
|
+
# Upsert semantics per (topic, url): if a subscription already exists
|
|
26
|
+
# for this topic and URL, rotate its secret instead of adding a new row.
|
|
27
|
+
# This mirrors typical real-world secret rotation flows where the
|
|
28
|
+
# endpoint remains the same but the signing secret changes.
|
|
29
|
+
lst = self._subs.setdefault(topic, [])
|
|
30
|
+
for sub in lst:
|
|
31
|
+
if sub.url == url:
|
|
32
|
+
sub.secret = secret
|
|
33
|
+
return
|
|
34
|
+
lst.append(WebhookSubscription(topic, url, secret))
|
|
35
|
+
|
|
36
|
+
def get_for_topic(self, topic: str) -> List[WebhookSubscription]:
|
|
37
|
+
return list(self._subs.get(topic, []))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WebhookService:
|
|
41
|
+
def __init__(self, outbox: OutboxStore, subs: InMemoryWebhookSubscriptions):
|
|
42
|
+
self._outbox = outbox
|
|
43
|
+
self._subs = subs
|
|
44
|
+
|
|
45
|
+
def publish(self, topic: str, payload: Dict, *, version: int = 1) -> int:
|
|
46
|
+
created_at = datetime.now(timezone.utc).isoformat()
|
|
47
|
+
base_event = {
|
|
48
|
+
"topic": topic,
|
|
49
|
+
"payload": payload,
|
|
50
|
+
"version": version,
|
|
51
|
+
"created_at": created_at,
|
|
52
|
+
}
|
|
53
|
+
# For each subscription, enqueue an outbox message with subscriber identity
|
|
54
|
+
last_id = 0
|
|
55
|
+
for sub in self._subs.get_for_topic(topic):
|
|
56
|
+
event = dict(base_event)
|
|
57
|
+
# Encrypt secret before storing in outbox for security
|
|
58
|
+
encrypted_secret = encrypt_secret(sub.secret)
|
|
59
|
+
msg_payload = {
|
|
60
|
+
"event": event,
|
|
61
|
+
"subscription": {
|
|
62
|
+
"id": sub.id,
|
|
63
|
+
"topic": sub.topic,
|
|
64
|
+
"url": sub.url,
|
|
65
|
+
"secret": encrypted_secret,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
msg = self._outbox.enqueue(topic, msg_payload)
|
|
69
|
+
last_id = msg.id
|
|
70
|
+
return last_id
|