boomerang-python 1.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.
- boomerang_python-1.0.0/.gitignore +32 -0
- boomerang_python-1.0.0/PKG-INFO +23 -0
- boomerang_python-1.0.0/README.md +3 -0
- boomerang_python-1.0.0/pyproject.toml +32 -0
- boomerang_python-1.0.0/src/boomerang/__init__.py +29 -0
- boomerang_python-1.0.0/src/boomerang/client.py +152 -0
- boomerang_python-1.0.0/src/boomerang/errors.py +60 -0
- boomerang_python-1.0.0/src/boomerang/middleware/__init__.py +0 -0
- boomerang_python-1.0.0/src/boomerang/middleware/fastapi.py +42 -0
- boomerang_python-1.0.0/src/boomerang/middleware/flask.py +48 -0
- boomerang_python-1.0.0/src/boomerang/models.py +55 -0
- boomerang_python-1.0.0/src/boomerang/py.typed +0 -0
- boomerang_python-1.0.0/src/boomerang/signature.py +37 -0
- boomerang_python-1.0.0/tests/__init__.py +0 -0
- boomerang_python-1.0.0/tests/test_client.py +206 -0
- boomerang_python-1.0.0/tests/test_errors.py +75 -0
- boomerang_python-1.0.0/tests/test_middleware_fastapi.py +68 -0
- boomerang_python-1.0.0/tests/test_middleware_flask.py +69 -0
- boomerang_python-1.0.0/tests/test_models.py +161 -0
- boomerang_python-1.0.0/tests/test_signature.py +66 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Maven
|
|
2
|
+
target/
|
|
3
|
+
*.class
|
|
4
|
+
.flattened-pom.xml
|
|
5
|
+
|
|
6
|
+
# IDE
|
|
7
|
+
.idea/
|
|
8
|
+
*.iml
|
|
9
|
+
.vscode/
|
|
10
|
+
*.swp
|
|
11
|
+
*.swo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Docs (medium posts, drafts, local assets)
|
|
15
|
+
docs/
|
|
16
|
+
|
|
17
|
+
# Private notes (not for public repo)
|
|
18
|
+
.private/
|
|
19
|
+
|
|
20
|
+
# macOS
|
|
21
|
+
.DS_Store
|
|
22
|
+
|
|
23
|
+
# Logs
|
|
24
|
+
*.log
|
|
25
|
+
|
|
26
|
+
# Python
|
|
27
|
+
__pycache__/
|
|
28
|
+
*.pyc
|
|
29
|
+
*.egg-info/
|
|
30
|
+
.venv/
|
|
31
|
+
dist/
|
|
32
|
+
.pytest_cache/
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: boomerang-python
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python SDK for the Boomerang async webhook platform
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: httpx<1,>=0.27
|
|
8
|
+
Requires-Dist: pydantic<3,>=2.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: fastapi>=0.100; extra == 'dev'
|
|
11
|
+
Requires-Dist: flask>=2.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
15
|
+
Provides-Extra: fastapi
|
|
16
|
+
Requires-Dist: fastapi>=0.100; extra == 'fastapi'
|
|
17
|
+
Provides-Extra: flask
|
|
18
|
+
Requires-Dist: flask>=2.0; extra == 'flask'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# boomerang-client
|
|
22
|
+
|
|
23
|
+
Python SDK for the [Boomerang](https://github.com/sameerchereddy/boomerang) async webhook platform.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "boomerang-python"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Python SDK for the Boomerang async webhook platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"pydantic>=2.0,<3",
|
|
14
|
+
"httpx>=0.27,<1",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
fastapi = ["fastapi>=0.100"]
|
|
19
|
+
flask = ["flask>=2.0"]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=8.0",
|
|
22
|
+
"pytest-asyncio>=0.23",
|
|
23
|
+
"pytest-httpx>=0.30",
|
|
24
|
+
"fastapi>=0.100",
|
|
25
|
+
"flask>=2.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/boomerang"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from .client import BoomerangClient
|
|
2
|
+
from .errors import (
|
|
3
|
+
BoomerangConflictError,
|
|
4
|
+
BoomerangError,
|
|
5
|
+
BoomerangForbiddenError,
|
|
6
|
+
BoomerangServiceUnavailableError,
|
|
7
|
+
BoomerangUnauthorizedError,
|
|
8
|
+
)
|
|
9
|
+
from .models import (
|
|
10
|
+
BoomerangJobStatus,
|
|
11
|
+
BoomerangPayload,
|
|
12
|
+
BoomerangTriggerRequest,
|
|
13
|
+
BoomerangTriggerResponse,
|
|
14
|
+
)
|
|
15
|
+
from .signature import BoomerangSignature
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BoomerangClient",
|
|
19
|
+
"BoomerangSignature",
|
|
20
|
+
"BoomerangError",
|
|
21
|
+
"BoomerangUnauthorizedError",
|
|
22
|
+
"BoomerangForbiddenError",
|
|
23
|
+
"BoomerangConflictError",
|
|
24
|
+
"BoomerangServiceUnavailableError",
|
|
25
|
+
"BoomerangTriggerRequest",
|
|
26
|
+
"BoomerangTriggerResponse",
|
|
27
|
+
"BoomerangJobStatus",
|
|
28
|
+
"BoomerangPayload",
|
|
29
|
+
]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .errors import (
|
|
8
|
+
BoomerangConflictError,
|
|
9
|
+
BoomerangError,
|
|
10
|
+
BoomerangForbiddenError,
|
|
11
|
+
BoomerangServiceUnavailableError,
|
|
12
|
+
BoomerangUnauthorizedError,
|
|
13
|
+
)
|
|
14
|
+
from .models import (
|
|
15
|
+
BoomerangJobStatus,
|
|
16
|
+
BoomerangTriggerRequest,
|
|
17
|
+
BoomerangTriggerResponse,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_STATUS_MAP: dict[int, type[BoomerangError]] = {
|
|
21
|
+
401: BoomerangUnauthorizedError,
|
|
22
|
+
403: BoomerangForbiddenError,
|
|
23
|
+
409: BoomerangConflictError,
|
|
24
|
+
503: BoomerangServiceUnavailableError,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BoomerangClient:
|
|
29
|
+
"""Thin HTTP client for the Boomerang async webhook service."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, base_url: str, token: str) -> None:
|
|
32
|
+
self._base_url = base_url.rstrip("/")
|
|
33
|
+
self._token = token
|
|
34
|
+
self._client = httpx.Client()
|
|
35
|
+
self._async_client = httpx.AsyncClient()
|
|
36
|
+
|
|
37
|
+
# --- lifecycle ---
|
|
38
|
+
|
|
39
|
+
def close(self) -> None:
|
|
40
|
+
"""Close the underlying connection pools."""
|
|
41
|
+
self._client.close()
|
|
42
|
+
|
|
43
|
+
async def aclose(self) -> None:
|
|
44
|
+
"""Close the underlying async connection pool."""
|
|
45
|
+
await self._async_client.aclose()
|
|
46
|
+
|
|
47
|
+
def __enter__(self) -> BoomerangClient:
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def __exit__(self, *args: object) -> None:
|
|
51
|
+
self.close()
|
|
52
|
+
|
|
53
|
+
async def __aenter__(self) -> BoomerangClient:
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
async def __aexit__(self, *args: object) -> None:
|
|
57
|
+
await self.aclose()
|
|
58
|
+
|
|
59
|
+
# --- headers ---
|
|
60
|
+
|
|
61
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
62
|
+
return {
|
|
63
|
+
"Authorization": f"Bearer {self._token}",
|
|
64
|
+
"Content-Type": "application/json",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# --- error handling ---
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
71
|
+
if response.is_success:
|
|
72
|
+
return
|
|
73
|
+
try:
|
|
74
|
+
body = response.json() if response.content else {}
|
|
75
|
+
except Exception:
|
|
76
|
+
body = {}
|
|
77
|
+
message = body.get("error", response.reason_phrase or "Unknown error")
|
|
78
|
+
error_cls = _STATUS_MAP.get(response.status_code)
|
|
79
|
+
if error_cls is BoomerangConflictError:
|
|
80
|
+
raise BoomerangConflictError(
|
|
81
|
+
message,
|
|
82
|
+
retry_after_seconds=body.get("retryAfterSeconds"),
|
|
83
|
+
)
|
|
84
|
+
if error_cls is not None:
|
|
85
|
+
raise error_cls(message)
|
|
86
|
+
raise BoomerangError(response.status_code, message)
|
|
87
|
+
|
|
88
|
+
# --- sync ---
|
|
89
|
+
|
|
90
|
+
def trigger(
|
|
91
|
+
self,
|
|
92
|
+
worker_url: str,
|
|
93
|
+
callback_url: str,
|
|
94
|
+
callback_secret: str | None = None,
|
|
95
|
+
idempotency_key: str | None = None,
|
|
96
|
+
) -> BoomerangTriggerResponse:
|
|
97
|
+
"""Submit a job via ``POST /sync``. Returns the assigned job ID."""
|
|
98
|
+
req = BoomerangTriggerRequest(
|
|
99
|
+
worker_url=worker_url,
|
|
100
|
+
callback_url=callback_url,
|
|
101
|
+
callback_secret=callback_secret,
|
|
102
|
+
idempotency_key=idempotency_key,
|
|
103
|
+
)
|
|
104
|
+
response = self._client.post(
|
|
105
|
+
f"{self._base_url}/sync",
|
|
106
|
+
headers=self._auth_headers(),
|
|
107
|
+
content=req.model_dump_json(by_alias=True, exclude_none=True),
|
|
108
|
+
)
|
|
109
|
+
self._raise_for_status(response)
|
|
110
|
+
return BoomerangTriggerResponse.model_validate(response.json())
|
|
111
|
+
|
|
112
|
+
def poll(self, job_id: str) -> BoomerangJobStatus:
|
|
113
|
+
"""Poll job status via ``GET /sync/{jobId}``."""
|
|
114
|
+
response = self._client.get(
|
|
115
|
+
f"{self._base_url}/sync/{quote(job_id, safe='')}",
|
|
116
|
+
headers=self._auth_headers(),
|
|
117
|
+
)
|
|
118
|
+
self._raise_for_status(response)
|
|
119
|
+
return BoomerangJobStatus.model_validate(response.json())
|
|
120
|
+
|
|
121
|
+
# --- async ---
|
|
122
|
+
|
|
123
|
+
async def trigger_async(
|
|
124
|
+
self,
|
|
125
|
+
worker_url: str,
|
|
126
|
+
callback_url: str,
|
|
127
|
+
callback_secret: str | None = None,
|
|
128
|
+
idempotency_key: str | None = None,
|
|
129
|
+
) -> BoomerangTriggerResponse:
|
|
130
|
+
"""Async variant of :meth:`trigger`."""
|
|
131
|
+
req = BoomerangTriggerRequest(
|
|
132
|
+
worker_url=worker_url,
|
|
133
|
+
callback_url=callback_url,
|
|
134
|
+
callback_secret=callback_secret,
|
|
135
|
+
idempotency_key=idempotency_key,
|
|
136
|
+
)
|
|
137
|
+
response = await self._async_client.post(
|
|
138
|
+
f"{self._base_url}/sync",
|
|
139
|
+
headers=self._auth_headers(),
|
|
140
|
+
content=req.model_dump_json(by_alias=True, exclude_none=True),
|
|
141
|
+
)
|
|
142
|
+
self._raise_for_status(response)
|
|
143
|
+
return BoomerangTriggerResponse.model_validate(response.json())
|
|
144
|
+
|
|
145
|
+
async def poll_async(self, job_id: str) -> BoomerangJobStatus:
|
|
146
|
+
"""Async variant of :meth:`poll`."""
|
|
147
|
+
response = await self._async_client.get(
|
|
148
|
+
f"{self._base_url}/sync/{quote(job_id, safe='')}",
|
|
149
|
+
headers=self._auth_headers(),
|
|
150
|
+
)
|
|
151
|
+
self._raise_for_status(response)
|
|
152
|
+
return BoomerangJobStatus.model_validate(response.json())
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BoomerangError(Exception):
|
|
5
|
+
"""Base class for all Boomerang SDK errors."""
|
|
6
|
+
|
|
7
|
+
status_code: int = 0
|
|
8
|
+
|
|
9
|
+
def __init__(self, status_code: int, message: str) -> None:
|
|
10
|
+
self.status_code = status_code
|
|
11
|
+
self.message = message
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
|
|
14
|
+
def __str__(self) -> str:
|
|
15
|
+
return f"[{self.status_code}] {self.message}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BoomerangUnauthorizedError(BoomerangError):
|
|
19
|
+
"""401 — missing or invalid JWT."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str = "Unauthorized") -> None:
|
|
22
|
+
super().__init__(401, message)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BoomerangForbiddenError(BoomerangError):
|
|
26
|
+
"""403 — callbackUrl or workerUrl not in the server's allowlist."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str = "Forbidden") -> None:
|
|
29
|
+
super().__init__(403, message)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BoomerangConflictError(BoomerangError):
|
|
33
|
+
"""409 — duplicate job submitted within idempotency cooldown."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str = "Conflict", retry_after_seconds: int | None = None) -> None:
|
|
36
|
+
super().__init__(409, message)
|
|
37
|
+
self.retry_after_seconds = retry_after_seconds
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BoomerangServiceUnavailableError(BoomerangError):
|
|
41
|
+
"""503 — Boomerang worker pool is saturated."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str = "Service Unavailable") -> None:
|
|
44
|
+
super().__init__(503, message)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
STATUS_CODE_TO_ERROR: dict[int, type[BoomerangError]] = {
|
|
48
|
+
401: BoomerangUnauthorizedError,
|
|
49
|
+
403: BoomerangForbiddenError,
|
|
50
|
+
409: BoomerangConflictError,
|
|
51
|
+
503: BoomerangServiceUnavailableError,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def raise_for_status(status_code: int, message: str) -> None:
|
|
56
|
+
"""Raise the appropriate ``BoomerangError`` subclass for *status_code*."""
|
|
57
|
+
error_cls = STATUS_CODE_TO_ERROR.get(status_code)
|
|
58
|
+
if error_cls is not None:
|
|
59
|
+
raise error_cls(message)
|
|
60
|
+
raise BoomerangError(status_code, message)
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""FastAPI dependency for verifying Boomerang webhook signatures."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from ..models import BoomerangPayload
|
|
6
|
+
from ..signature import BoomerangSignature
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def boomerang_webhook(secret: str) -> Callable:
|
|
10
|
+
"""Return a FastAPI dependency callable that verifies the webhook
|
|
11
|
+
signature and returns a :class:`BoomerangPayload`.
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
from fastapi import Depends
|
|
16
|
+
|
|
17
|
+
@app.post("/hooks/sync-done")
|
|
18
|
+
async def on_sync_done(
|
|
19
|
+
payload: BoomerangPayload = Depends(boomerang_webhook(secret)),
|
|
20
|
+
):
|
|
21
|
+
...
|
|
22
|
+
"""
|
|
23
|
+
from fastapi import HTTPException, Request
|
|
24
|
+
|
|
25
|
+
async def _verify(request: Request) -> BoomerangPayload:
|
|
26
|
+
signature = request.headers.get("x-signature-sha256")
|
|
27
|
+
if not signature:
|
|
28
|
+
raise HTTPException(status_code=401, detail="Missing X-Signature-SHA256 header")
|
|
29
|
+
|
|
30
|
+
body = await request.body()
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
valid = BoomerangSignature.verify(body, signature, secret)
|
|
34
|
+
except ValueError:
|
|
35
|
+
raise HTTPException(status_code=401, detail="Malformed signature header")
|
|
36
|
+
|
|
37
|
+
if not valid:
|
|
38
|
+
raise HTTPException(status_code=401, detail="Invalid signature")
|
|
39
|
+
|
|
40
|
+
return BoomerangPayload.model_validate_json(body)
|
|
41
|
+
|
|
42
|
+
return _verify
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Flask decorator for verifying Boomerang webhook signatures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from ..models import BoomerangPayload
|
|
9
|
+
from ..signature import BoomerangSignature
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def boomerang_webhook(secret: str) -> Callable:
|
|
13
|
+
"""Decorator that verifies the webhook signature and injects a
|
|
14
|
+
:class:`BoomerangPayload` as the first positional argument.
|
|
15
|
+
|
|
16
|
+
Usage::
|
|
17
|
+
|
|
18
|
+
@app.post("/hooks/sync-done")
|
|
19
|
+
@boomerang_webhook(secret=os.environ["WEBHOOK_SECRET"])
|
|
20
|
+
def on_sync_done(payload: BoomerangPayload):
|
|
21
|
+
...
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
25
|
+
@functools.wraps(fn)
|
|
26
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
27
|
+
from flask import abort, request # noqa: lazy import
|
|
28
|
+
|
|
29
|
+
signature = request.headers.get("x-signature-sha256")
|
|
30
|
+
if not signature:
|
|
31
|
+
abort(401, description="Missing X-Signature-SHA256 header")
|
|
32
|
+
|
|
33
|
+
body = request.get_data()
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
valid = BoomerangSignature.verify(body, signature, secret)
|
|
37
|
+
except ValueError:
|
|
38
|
+
abort(401, description="Malformed signature header")
|
|
39
|
+
|
|
40
|
+
if not valid:
|
|
41
|
+
abort(401, description="Invalid signature")
|
|
42
|
+
|
|
43
|
+
payload = BoomerangPayload.model_validate_json(body)
|
|
44
|
+
return fn(payload, *args, **kwargs)
|
|
45
|
+
|
|
46
|
+
return wrapper
|
|
47
|
+
|
|
48
|
+
return decorator
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _CamelModel(BaseModel):
|
|
10
|
+
"""Base model that serialises snake_case fields to camelCase JSON."""
|
|
11
|
+
|
|
12
|
+
model_config = ConfigDict(
|
|
13
|
+
populate_by_name=True,
|
|
14
|
+
frozen=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BoomerangTriggerRequest(_CamelModel):
|
|
19
|
+
"""Request body for ``POST /sync``."""
|
|
20
|
+
|
|
21
|
+
worker_url: str = Field(alias="workerUrl")
|
|
22
|
+
callback_url: str = Field(alias="callbackUrl")
|
|
23
|
+
callback_secret: str | None = Field(default=None, alias="callbackSecret")
|
|
24
|
+
idempotency_key: str | None = Field(default=None, alias="idempotencyKey")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BoomerangTriggerResponse(_CamelModel):
|
|
28
|
+
"""Response body returned by ``POST /sync``."""
|
|
29
|
+
|
|
30
|
+
job_id: str = Field(alias="jobId")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BoomerangJobStatus(_CamelModel):
|
|
34
|
+
"""Response body returned by ``GET /sync/{jobId}``."""
|
|
35
|
+
|
|
36
|
+
job_id: str = Field(alias="jobId")
|
|
37
|
+
status: Literal["PENDING", "IN_PROGRESS", "DONE", "FAILED"]
|
|
38
|
+
created_at: str | None = Field(default=None, alias="createdAt")
|
|
39
|
+
completed_at: str | None = Field(default=None, alias="completedAt")
|
|
40
|
+
result: dict[str, Any] | None = None
|
|
41
|
+
error: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BoomerangPayload(_CamelModel):
|
|
45
|
+
"""Webhook callback payload delivered to the consumer's ``callbackUrl``.
|
|
46
|
+
|
|
47
|
+
This model is immutable (frozen) so middleware can safely pass it around.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
boomerang_version: str = Field(alias="boomerangVersion")
|
|
51
|
+
job_id: str = Field(alias="jobId")
|
|
52
|
+
status: Literal["DONE", "FAILED"]
|
|
53
|
+
completed_at: datetime = Field(alias="completedAt")
|
|
54
|
+
result: dict[str, Any] | None = None
|
|
55
|
+
error: str | None = None
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BoomerangSignature:
|
|
8
|
+
"""HMAC-SHA256 signature verification for Boomerang webhooks."""
|
|
9
|
+
|
|
10
|
+
_PREFIX = "sha256="
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def verify(body: bytes, signature_header: str, secret: str) -> bool:
|
|
14
|
+
"""Return ``True`` if *signature_header* is a valid HMAC for *body*.
|
|
15
|
+
|
|
16
|
+
Uses constant-time comparison to prevent timing attacks.
|
|
17
|
+
|
|
18
|
+
Raises ``ValueError`` if *signature_header* does not start with
|
|
19
|
+
``sha256=``.
|
|
20
|
+
"""
|
|
21
|
+
if not signature_header.startswith(BoomerangSignature._PREFIX):
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"Malformed signature header: expected 'sha256=...' prefix, "
|
|
24
|
+
f"got {signature_header!r}"
|
|
25
|
+
)
|
|
26
|
+
expected = BoomerangSignature.compute(body, secret)
|
|
27
|
+
return hmac.compare_digest(expected, signature_header)
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def compute(body: bytes, secret: str) -> str:
|
|
31
|
+
"""Return the signature header value: ``sha256=<lowercase hex>``."""
|
|
32
|
+
digest = hmac.new(
|
|
33
|
+
secret.encode("utf-8"),
|
|
34
|
+
body,
|
|
35
|
+
hashlib.sha256,
|
|
36
|
+
).hexdigest()
|
|
37
|
+
return f"sha256={digest}"
|
|
File without changes
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from boomerang import (
|
|
7
|
+
BoomerangConflictError,
|
|
8
|
+
BoomerangForbiddenError,
|
|
9
|
+
BoomerangServiceUnavailableError,
|
|
10
|
+
BoomerangUnauthorizedError,
|
|
11
|
+
)
|
|
12
|
+
from boomerang.client import BoomerangClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
BASE = "https://boomerang.test"
|
|
16
|
+
TOKEN = "test-jwt"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _client() -> BoomerangClient:
|
|
20
|
+
return BoomerangClient(base_url=BASE, token=TOKEN)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _mock_response(status: int, body: dict | None = None) -> httpx.Response:
|
|
24
|
+
return httpx.Response(
|
|
25
|
+
status_code=status,
|
|
26
|
+
json=body or {},
|
|
27
|
+
request=httpx.Request("GET", BASE),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── trigger ──────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestTrigger:
|
|
35
|
+
def test_success(self, httpx_mock):
|
|
36
|
+
httpx_mock.add_response(
|
|
37
|
+
url=f"{BASE}/sync",
|
|
38
|
+
method="POST",
|
|
39
|
+
status_code=202,
|
|
40
|
+
json={"jobId": "j1"},
|
|
41
|
+
)
|
|
42
|
+
resp = _client().trigger(
|
|
43
|
+
worker_url="https://w.co/work",
|
|
44
|
+
callback_url="https://c.co/hook",
|
|
45
|
+
)
|
|
46
|
+
assert resp.job_id == "j1"
|
|
47
|
+
|
|
48
|
+
def test_sends_auth_header(self, httpx_mock):
|
|
49
|
+
httpx_mock.add_response(
|
|
50
|
+
url=f"{BASE}/sync",
|
|
51
|
+
method="POST",
|
|
52
|
+
status_code=202,
|
|
53
|
+
json={"jobId": "j1"},
|
|
54
|
+
)
|
|
55
|
+
_client().trigger(
|
|
56
|
+
worker_url="https://w.co/work",
|
|
57
|
+
callback_url="https://c.co/hook",
|
|
58
|
+
)
|
|
59
|
+
request = httpx_mock.get_request()
|
|
60
|
+
assert request.headers["authorization"] == f"Bearer {TOKEN}"
|
|
61
|
+
|
|
62
|
+
def test_sends_camel_case_body(self, httpx_mock):
|
|
63
|
+
httpx_mock.add_response(
|
|
64
|
+
url=f"{BASE}/sync",
|
|
65
|
+
method="POST",
|
|
66
|
+
status_code=202,
|
|
67
|
+
json={"jobId": "j1"},
|
|
68
|
+
)
|
|
69
|
+
_client().trigger(
|
|
70
|
+
worker_url="https://w.co/work",
|
|
71
|
+
callback_url="https://c.co/hook",
|
|
72
|
+
callback_secret="s",
|
|
73
|
+
idempotency_key="k",
|
|
74
|
+
)
|
|
75
|
+
body = json.loads(httpx_mock.get_request().content)
|
|
76
|
+
assert body == {
|
|
77
|
+
"workerUrl": "https://w.co/work",
|
|
78
|
+
"callbackUrl": "https://c.co/hook",
|
|
79
|
+
"callbackSecret": "s",
|
|
80
|
+
"idempotencyKey": "k",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def test_excludes_none_fields(self, httpx_mock):
|
|
84
|
+
httpx_mock.add_response(
|
|
85
|
+
url=f"{BASE}/sync",
|
|
86
|
+
method="POST",
|
|
87
|
+
status_code=202,
|
|
88
|
+
json={"jobId": "j1"},
|
|
89
|
+
)
|
|
90
|
+
_client().trigger(
|
|
91
|
+
worker_url="https://w.co/work",
|
|
92
|
+
callback_url="https://c.co/hook",
|
|
93
|
+
)
|
|
94
|
+
body = json.loads(httpx_mock.get_request().content)
|
|
95
|
+
assert "callbackSecret" not in body
|
|
96
|
+
assert "idempotencyKey" not in body
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── poll ─────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestPoll:
|
|
103
|
+
def test_success(self, httpx_mock):
|
|
104
|
+
httpx_mock.add_response(
|
|
105
|
+
url=f"{BASE}/sync/j1",
|
|
106
|
+
method="GET",
|
|
107
|
+
json={"jobId": "j1", "status": "DONE", "completedAt": "2026-01-01T00:00:00Z"},
|
|
108
|
+
)
|
|
109
|
+
status = _client().poll("j1")
|
|
110
|
+
assert status.job_id == "j1"
|
|
111
|
+
assert status.status == "DONE"
|
|
112
|
+
|
|
113
|
+
def test_url_encodes_job_id(self, httpx_mock):
|
|
114
|
+
httpx_mock.add_response(
|
|
115
|
+
url=f"{BASE}/sync/a%2Fb",
|
|
116
|
+
method="GET",
|
|
117
|
+
json={"jobId": "a/b", "status": "PENDING"},
|
|
118
|
+
)
|
|
119
|
+
status = _client().poll("a/b")
|
|
120
|
+
assert status.job_id == "a/b"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── error mapping ───────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TestErrorMapping:
|
|
127
|
+
@pytest.mark.parametrize(
|
|
128
|
+
"status,error_cls",
|
|
129
|
+
[
|
|
130
|
+
(401, BoomerangUnauthorizedError),
|
|
131
|
+
(403, BoomerangForbiddenError),
|
|
132
|
+
(409, BoomerangConflictError),
|
|
133
|
+
(503, BoomerangServiceUnavailableError),
|
|
134
|
+
],
|
|
135
|
+
)
|
|
136
|
+
def test_known_errors(self, httpx_mock, status, error_cls):
|
|
137
|
+
httpx_mock.add_response(
|
|
138
|
+
url=f"{BASE}/sync",
|
|
139
|
+
method="POST",
|
|
140
|
+
status_code=status,
|
|
141
|
+
json={"error": "boom"},
|
|
142
|
+
)
|
|
143
|
+
with pytest.raises(error_cls, match="boom"):
|
|
144
|
+
_client().trigger(
|
|
145
|
+
worker_url="https://w.co/work",
|
|
146
|
+
callback_url="https://c.co/hook",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def test_conflict_retry_after(self, httpx_mock):
|
|
150
|
+
httpx_mock.add_response(
|
|
151
|
+
url=f"{BASE}/sync",
|
|
152
|
+
method="POST",
|
|
153
|
+
status_code=409,
|
|
154
|
+
json={"error": "dup", "retryAfterSeconds": 30},
|
|
155
|
+
)
|
|
156
|
+
with pytest.raises(BoomerangConflictError) as exc_info:
|
|
157
|
+
_client().trigger(
|
|
158
|
+
worker_url="https://w.co/work",
|
|
159
|
+
callback_url="https://c.co/hook",
|
|
160
|
+
)
|
|
161
|
+
assert exc_info.value.retry_after_seconds == 30
|
|
162
|
+
|
|
163
|
+
def test_unknown_error(self, httpx_mock):
|
|
164
|
+
from boomerang.errors import BoomerangError
|
|
165
|
+
|
|
166
|
+
httpx_mock.add_response(
|
|
167
|
+
url=f"{BASE}/sync",
|
|
168
|
+
method="POST",
|
|
169
|
+
status_code=500,
|
|
170
|
+
json={"error": "internal"},
|
|
171
|
+
)
|
|
172
|
+
with pytest.raises(BoomerangError) as exc_info:
|
|
173
|
+
_client().trigger(
|
|
174
|
+
worker_url="https://w.co/work",
|
|
175
|
+
callback_url="https://c.co/hook",
|
|
176
|
+
)
|
|
177
|
+
assert exc_info.value.status_code == 500
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ── async ────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class TestAsync:
|
|
184
|
+
@pytest.mark.asyncio
|
|
185
|
+
async def test_trigger_async(self, httpx_mock):
|
|
186
|
+
httpx_mock.add_response(
|
|
187
|
+
url=f"{BASE}/sync",
|
|
188
|
+
method="POST",
|
|
189
|
+
status_code=202,
|
|
190
|
+
json={"jobId": "j1"},
|
|
191
|
+
)
|
|
192
|
+
resp = await _client().trigger_async(
|
|
193
|
+
worker_url="https://w.co/work",
|
|
194
|
+
callback_url="https://c.co/hook",
|
|
195
|
+
)
|
|
196
|
+
assert resp.job_id == "j1"
|
|
197
|
+
|
|
198
|
+
@pytest.mark.asyncio
|
|
199
|
+
async def test_poll_async(self, httpx_mock):
|
|
200
|
+
httpx_mock.add_response(
|
|
201
|
+
url=f"{BASE}/sync/j1",
|
|
202
|
+
method="GET",
|
|
203
|
+
json={"jobId": "j1", "status": "PENDING"},
|
|
204
|
+
)
|
|
205
|
+
status = await _client().poll_async("j1")
|
|
206
|
+
assert status.status == "PENDING"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from boomerang.errors import (
|
|
4
|
+
BoomerangConflictError,
|
|
5
|
+
BoomerangError,
|
|
6
|
+
BoomerangForbiddenError,
|
|
7
|
+
BoomerangServiceUnavailableError,
|
|
8
|
+
BoomerangUnauthorizedError,
|
|
9
|
+
raise_for_status,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestBoomerangError:
|
|
14
|
+
def test_base_error(self):
|
|
15
|
+
err = BoomerangError(500, "Internal Server Error")
|
|
16
|
+
assert err.status_code == 500
|
|
17
|
+
assert err.message == "Internal Server Error"
|
|
18
|
+
assert str(err) == "[500] Internal Server Error"
|
|
19
|
+
|
|
20
|
+
def test_is_exception(self):
|
|
21
|
+
assert issubclass(BoomerangError, Exception)
|
|
22
|
+
|
|
23
|
+
def test_catchable_by_base(self):
|
|
24
|
+
with pytest.raises(BoomerangError):
|
|
25
|
+
raise BoomerangUnauthorizedError("bad token")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestSubclasses:
|
|
29
|
+
def test_unauthorized(self):
|
|
30
|
+
err = BoomerangUnauthorizedError("Invalid JWT")
|
|
31
|
+
assert err.status_code == 401
|
|
32
|
+
assert str(err) == "[401] Invalid JWT"
|
|
33
|
+
|
|
34
|
+
def test_unauthorized_default(self):
|
|
35
|
+
err = BoomerangUnauthorizedError()
|
|
36
|
+
assert str(err) == "[401] Unauthorized"
|
|
37
|
+
|
|
38
|
+
def test_forbidden(self):
|
|
39
|
+
err = BoomerangForbiddenError("URL not allowed")
|
|
40
|
+
assert err.status_code == 403
|
|
41
|
+
|
|
42
|
+
def test_conflict(self):
|
|
43
|
+
err = BoomerangConflictError("Duplicate key", retry_after_seconds=30)
|
|
44
|
+
assert err.status_code == 409
|
|
45
|
+
assert err.retry_after_seconds == 30
|
|
46
|
+
|
|
47
|
+
def test_conflict_no_retry(self):
|
|
48
|
+
err = BoomerangConflictError()
|
|
49
|
+
assert err.retry_after_seconds is None
|
|
50
|
+
|
|
51
|
+
def test_service_unavailable(self):
|
|
52
|
+
err = BoomerangServiceUnavailableError("Pool full")
|
|
53
|
+
assert err.status_code == 503
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TestRaiseForStatus:
|
|
57
|
+
@pytest.mark.parametrize(
|
|
58
|
+
"code,expected_cls",
|
|
59
|
+
[
|
|
60
|
+
(401, BoomerangUnauthorizedError),
|
|
61
|
+
(403, BoomerangForbiddenError),
|
|
62
|
+
(409, BoomerangConflictError),
|
|
63
|
+
(503, BoomerangServiceUnavailableError),
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
def test_known_codes(self, code, expected_cls):
|
|
67
|
+
with pytest.raises(expected_cls) as exc_info:
|
|
68
|
+
raise_for_status(code, "msg")
|
|
69
|
+
assert exc_info.value.status_code == code
|
|
70
|
+
|
|
71
|
+
def test_unknown_code_raises_base(self):
|
|
72
|
+
with pytest.raises(BoomerangError) as exc_info:
|
|
73
|
+
raise_for_status(502, "Bad Gateway")
|
|
74
|
+
assert exc_info.value.status_code == 502
|
|
75
|
+
assert not isinstance(exc_info.value, BoomerangUnauthorizedError)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from boomerang.signature import BoomerangSignature
|
|
6
|
+
|
|
7
|
+
SECRET = "test-secret"
|
|
8
|
+
PAYLOAD = {
|
|
9
|
+
"boomerangVersion": "1",
|
|
10
|
+
"jobId": "j1",
|
|
11
|
+
"status": "DONE",
|
|
12
|
+
"completedAt": "2026-03-22T10:00:18Z",
|
|
13
|
+
"result": {"key": "value"},
|
|
14
|
+
}
|
|
15
|
+
BODY = json.dumps(PAYLOAD).encode()
|
|
16
|
+
VALID_SIG = BoomerangSignature.compute(BODY, SECRET)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture()
|
|
20
|
+
def fastapi_client():
|
|
21
|
+
from fastapi import Depends, FastAPI
|
|
22
|
+
from fastapi.testclient import TestClient
|
|
23
|
+
|
|
24
|
+
from boomerang.middleware.fastapi import boomerang_webhook
|
|
25
|
+
from boomerang.models import BoomerangPayload
|
|
26
|
+
|
|
27
|
+
app = FastAPI()
|
|
28
|
+
|
|
29
|
+
@app.post("/hooks")
|
|
30
|
+
async def hook(payload: BoomerangPayload = Depends(boomerang_webhook(SECRET))):
|
|
31
|
+
return {"job_id": payload.job_id, "status": payload.status}
|
|
32
|
+
|
|
33
|
+
return TestClient(app)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestFastAPIMiddleware:
|
|
37
|
+
def test_valid_signature(self, fastapi_client):
|
|
38
|
+
resp = fastapi_client.post(
|
|
39
|
+
"/hooks",
|
|
40
|
+
content=BODY,
|
|
41
|
+
headers={"X-Signature-SHA256": VALID_SIG, "Content-Type": "application/json"},
|
|
42
|
+
)
|
|
43
|
+
assert resp.status_code == 200
|
|
44
|
+
assert resp.json()["job_id"] == "j1"
|
|
45
|
+
|
|
46
|
+
def test_missing_signature_returns_401(self, fastapi_client):
|
|
47
|
+
resp = fastapi_client.post(
|
|
48
|
+
"/hooks",
|
|
49
|
+
content=BODY,
|
|
50
|
+
headers={"Content-Type": "application/json"},
|
|
51
|
+
)
|
|
52
|
+
assert resp.status_code == 401
|
|
53
|
+
|
|
54
|
+
def test_invalid_signature_returns_401(self, fastapi_client):
|
|
55
|
+
resp = fastapi_client.post(
|
|
56
|
+
"/hooks",
|
|
57
|
+
content=BODY,
|
|
58
|
+
headers={"X-Signature-SHA256": "sha256=" + "a" * 64, "Content-Type": "application/json"},
|
|
59
|
+
)
|
|
60
|
+
assert resp.status_code == 401
|
|
61
|
+
|
|
62
|
+
def test_malformed_signature_returns_401(self, fastapi_client):
|
|
63
|
+
resp = fastapi_client.post(
|
|
64
|
+
"/hooks",
|
|
65
|
+
content=BODY,
|
|
66
|
+
headers={"X-Signature-SHA256": "bad", "Content-Type": "application/json"},
|
|
67
|
+
)
|
|
68
|
+
assert resp.status_code == 401
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from boomerang.signature import BoomerangSignature
|
|
6
|
+
|
|
7
|
+
SECRET = "test-secret"
|
|
8
|
+
PAYLOAD = {
|
|
9
|
+
"boomerangVersion": "1",
|
|
10
|
+
"jobId": "j1",
|
|
11
|
+
"status": "DONE",
|
|
12
|
+
"completedAt": "2026-03-22T10:00:18Z",
|
|
13
|
+
"result": {"key": "value"},
|
|
14
|
+
}
|
|
15
|
+
BODY = json.dumps(PAYLOAD).encode()
|
|
16
|
+
VALID_SIG = BoomerangSignature.compute(BODY, SECRET)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture()
|
|
20
|
+
def flask_client():
|
|
21
|
+
from flask import Flask, jsonify
|
|
22
|
+
|
|
23
|
+
from boomerang.middleware.flask import boomerang_webhook
|
|
24
|
+
from boomerang.models import BoomerangPayload
|
|
25
|
+
|
|
26
|
+
app = Flask(__name__)
|
|
27
|
+
|
|
28
|
+
@app.post("/hooks")
|
|
29
|
+
@boomerang_webhook(secret=SECRET)
|
|
30
|
+
def hook(payload: BoomerangPayload):
|
|
31
|
+
return jsonify(job_id=payload.job_id, status=payload.status)
|
|
32
|
+
|
|
33
|
+
app.config["TESTING"] = True
|
|
34
|
+
return app.test_client()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestFlaskMiddleware:
|
|
38
|
+
def test_valid_signature(self, flask_client):
|
|
39
|
+
resp = flask_client.post(
|
|
40
|
+
"/hooks",
|
|
41
|
+
data=BODY,
|
|
42
|
+
headers={"X-Signature-SHA256": VALID_SIG, "Content-Type": "application/json"},
|
|
43
|
+
)
|
|
44
|
+
assert resp.status_code == 200
|
|
45
|
+
assert resp.get_json()["job_id"] == "j1"
|
|
46
|
+
|
|
47
|
+
def test_missing_signature_returns_401(self, flask_client):
|
|
48
|
+
resp = flask_client.post(
|
|
49
|
+
"/hooks",
|
|
50
|
+
data=BODY,
|
|
51
|
+
headers={"Content-Type": "application/json"},
|
|
52
|
+
)
|
|
53
|
+
assert resp.status_code == 401
|
|
54
|
+
|
|
55
|
+
def test_invalid_signature_returns_401(self, flask_client):
|
|
56
|
+
resp = flask_client.post(
|
|
57
|
+
"/hooks",
|
|
58
|
+
data=BODY,
|
|
59
|
+
headers={"X-Signature-SHA256": "sha256=" + "a" * 64, "Content-Type": "application/json"},
|
|
60
|
+
)
|
|
61
|
+
assert resp.status_code == 401
|
|
62
|
+
|
|
63
|
+
def test_malformed_signature_returns_401(self, flask_client):
|
|
64
|
+
resp = flask_client.post(
|
|
65
|
+
"/hooks",
|
|
66
|
+
data=BODY,
|
|
67
|
+
headers={"X-Signature-SHA256": "bad", "Content-Type": "application/json"},
|
|
68
|
+
)
|
|
69
|
+
assert resp.status_code == 401
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pydantic import ValidationError
|
|
5
|
+
|
|
6
|
+
from boomerang import (
|
|
7
|
+
BoomerangJobStatus,
|
|
8
|
+
BoomerangPayload,
|
|
9
|
+
BoomerangTriggerRequest,
|
|
10
|
+
BoomerangTriggerResponse,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestBoomerangTriggerRequest:
|
|
15
|
+
def test_required_fields(self):
|
|
16
|
+
req = BoomerangTriggerRequest(
|
|
17
|
+
worker_url="https://example.com/worker",
|
|
18
|
+
callback_url="https://example.com/callback",
|
|
19
|
+
)
|
|
20
|
+
assert req.worker_url == "https://example.com/worker"
|
|
21
|
+
assert req.callback_url == "https://example.com/callback"
|
|
22
|
+
assert req.callback_secret is None
|
|
23
|
+
assert req.idempotency_key is None
|
|
24
|
+
|
|
25
|
+
def test_all_fields(self):
|
|
26
|
+
req = BoomerangTriggerRequest(
|
|
27
|
+
worker_url="https://example.com/worker",
|
|
28
|
+
callback_url="https://example.com/callback",
|
|
29
|
+
callback_secret="secret",
|
|
30
|
+
idempotency_key="key-123",
|
|
31
|
+
)
|
|
32
|
+
assert req.callback_secret == "secret"
|
|
33
|
+
assert req.idempotency_key == "key-123"
|
|
34
|
+
|
|
35
|
+
def test_serialise_to_camel_case(self):
|
|
36
|
+
req = BoomerangTriggerRequest(
|
|
37
|
+
worker_url="https://example.com/worker",
|
|
38
|
+
callback_url="https://example.com/callback",
|
|
39
|
+
callback_secret="s",
|
|
40
|
+
)
|
|
41
|
+
data = req.model_dump(by_alias=True, exclude_none=True)
|
|
42
|
+
assert data == {
|
|
43
|
+
"workerUrl": "https://example.com/worker",
|
|
44
|
+
"callbackUrl": "https://example.com/callback",
|
|
45
|
+
"callbackSecret": "s",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def test_deserialise_from_camel_case(self):
|
|
49
|
+
req = BoomerangTriggerRequest.model_validate(
|
|
50
|
+
{"workerUrl": "https://w.co", "callbackUrl": "https://c.co"}
|
|
51
|
+
)
|
|
52
|
+
assert req.worker_url == "https://w.co"
|
|
53
|
+
|
|
54
|
+
def test_immutable(self):
|
|
55
|
+
req = BoomerangTriggerRequest(
|
|
56
|
+
worker_url="https://example.com/worker",
|
|
57
|
+
callback_url="https://example.com/callback",
|
|
58
|
+
)
|
|
59
|
+
with pytest.raises(ValidationError):
|
|
60
|
+
req.worker_url = "https://other.com"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestBoomerangTriggerResponse:
|
|
64
|
+
def test_from_json(self):
|
|
65
|
+
resp = BoomerangTriggerResponse.model_validate({"jobId": "abc-123"})
|
|
66
|
+
assert resp.job_id == "abc-123"
|
|
67
|
+
|
|
68
|
+
def test_serialise(self):
|
|
69
|
+
resp = BoomerangTriggerResponse(job_id="abc-123")
|
|
70
|
+
assert resp.model_dump(by_alias=True) == {"jobId": "abc-123"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestBoomerangJobStatus:
|
|
74
|
+
def test_pending(self):
|
|
75
|
+
status = BoomerangJobStatus.model_validate(
|
|
76
|
+
{"jobId": "j1", "status": "PENDING"}
|
|
77
|
+
)
|
|
78
|
+
assert status.status == "PENDING"
|
|
79
|
+
assert status.completed_at is None
|
|
80
|
+
assert status.result is None
|
|
81
|
+
|
|
82
|
+
def test_done(self):
|
|
83
|
+
status = BoomerangJobStatus.model_validate(
|
|
84
|
+
{
|
|
85
|
+
"jobId": "j1",
|
|
86
|
+
"status": "DONE",
|
|
87
|
+
"completedAt": "2026-03-22T10:00:18Z",
|
|
88
|
+
"result": {"key": "value"},
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
assert status.status == "DONE"
|
|
92
|
+
assert status.result == {"key": "value"}
|
|
93
|
+
|
|
94
|
+
def test_failed(self):
|
|
95
|
+
status = BoomerangJobStatus.model_validate(
|
|
96
|
+
{"jobId": "j1", "status": "FAILED", "error": "boom"}
|
|
97
|
+
)
|
|
98
|
+
assert status.status == "FAILED"
|
|
99
|
+
assert status.error == "boom"
|
|
100
|
+
|
|
101
|
+
def test_invalid_status_rejected(self):
|
|
102
|
+
with pytest.raises(ValidationError):
|
|
103
|
+
BoomerangJobStatus.model_validate(
|
|
104
|
+
{"jobId": "j1", "status": "UNKNOWN"}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TestBoomerangPayload:
|
|
109
|
+
def test_done_payload(self):
|
|
110
|
+
payload = BoomerangPayload.model_validate(
|
|
111
|
+
{
|
|
112
|
+
"boomerangVersion": "1",
|
|
113
|
+
"jobId": "j1",
|
|
114
|
+
"status": "DONE",
|
|
115
|
+
"completedAt": "2026-03-22T10:00:18Z",
|
|
116
|
+
"result": {"report": "url"},
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
assert payload.boomerang_version == "1"
|
|
120
|
+
assert payload.job_id == "j1"
|
|
121
|
+
assert payload.status == "DONE"
|
|
122
|
+
assert isinstance(payload.completed_at, datetime)
|
|
123
|
+
assert payload.result == {"report": "url"}
|
|
124
|
+
assert payload.error is None
|
|
125
|
+
|
|
126
|
+
def test_failed_payload(self):
|
|
127
|
+
payload = BoomerangPayload.model_validate(
|
|
128
|
+
{
|
|
129
|
+
"boomerangVersion": "1",
|
|
130
|
+
"jobId": "j1",
|
|
131
|
+
"status": "FAILED",
|
|
132
|
+
"completedAt": "2026-03-22T10:00:18Z",
|
|
133
|
+
"error": "timeout",
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
assert payload.error == "timeout"
|
|
137
|
+
assert payload.result is None
|
|
138
|
+
|
|
139
|
+
def test_immutable(self):
|
|
140
|
+
payload = BoomerangPayload.model_validate(
|
|
141
|
+
{
|
|
142
|
+
"boomerangVersion": "1",
|
|
143
|
+
"jobId": "j1",
|
|
144
|
+
"status": "DONE",
|
|
145
|
+
"completedAt": "2026-03-22T10:00:18Z",
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
with pytest.raises(ValidationError):
|
|
149
|
+
payload.job_id = "other"
|
|
150
|
+
|
|
151
|
+
def test_serialise_to_camel_case(self):
|
|
152
|
+
payload = BoomerangPayload(
|
|
153
|
+
boomerang_version="1",
|
|
154
|
+
job_id="j1",
|
|
155
|
+
status="DONE",
|
|
156
|
+
completed_at=datetime(2026, 3, 22, 10, 0, 18, tzinfo=timezone.utc),
|
|
157
|
+
)
|
|
158
|
+
data = payload.model_dump(by_alias=True, exclude_none=True)
|
|
159
|
+
assert "boomerangVersion" in data
|
|
160
|
+
assert "jobId" in data
|
|
161
|
+
assert "completedAt" in data
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from boomerang.signature import BoomerangSignature
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SECRET = "test-secret"
|
|
10
|
+
BODY = b'{"jobId":"abc-123","status":"DONE"}'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _expected_sig(body: bytes = BODY, secret: str = SECRET) -> str:
|
|
14
|
+
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
15
|
+
return f"sha256={digest}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestCompute:
|
|
19
|
+
def test_returns_sha256_prefix(self):
|
|
20
|
+
result = BoomerangSignature.compute(BODY, SECRET)
|
|
21
|
+
assert result.startswith("sha256=")
|
|
22
|
+
|
|
23
|
+
def test_lowercase_hex(self):
|
|
24
|
+
result = BoomerangSignature.compute(BODY, SECRET)
|
|
25
|
+
hex_part = result.removeprefix("sha256=")
|
|
26
|
+
assert hex_part == hex_part.lower()
|
|
27
|
+
assert len(hex_part) == 64 # SHA-256 = 64 hex chars
|
|
28
|
+
|
|
29
|
+
def test_matches_stdlib_hmac(self):
|
|
30
|
+
assert BoomerangSignature.compute(BODY, SECRET) == _expected_sig()
|
|
31
|
+
|
|
32
|
+
def test_different_secret_different_sig(self):
|
|
33
|
+
sig1 = BoomerangSignature.compute(BODY, "secret-a")
|
|
34
|
+
sig2 = BoomerangSignature.compute(BODY, "secret-b")
|
|
35
|
+
assert sig1 != sig2
|
|
36
|
+
|
|
37
|
+
def test_different_body_different_sig(self):
|
|
38
|
+
sig1 = BoomerangSignature.compute(b"body-a", SECRET)
|
|
39
|
+
sig2 = BoomerangSignature.compute(b"body-b", SECRET)
|
|
40
|
+
assert sig1 != sig2
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestVerify:
|
|
44
|
+
def test_valid_signature(self):
|
|
45
|
+
sig = _expected_sig()
|
|
46
|
+
assert BoomerangSignature.verify(BODY, sig, SECRET) is True
|
|
47
|
+
|
|
48
|
+
def test_invalid_signature(self):
|
|
49
|
+
bad_sig = "sha256=" + "a" * 64
|
|
50
|
+
assert BoomerangSignature.verify(BODY, bad_sig, SECRET) is False
|
|
51
|
+
|
|
52
|
+
def test_wrong_secret(self):
|
|
53
|
+
sig = _expected_sig(secret="wrong")
|
|
54
|
+
assert BoomerangSignature.verify(BODY, sig, SECRET) is False
|
|
55
|
+
|
|
56
|
+
def test_malformed_header_raises(self):
|
|
57
|
+
with pytest.raises(ValueError, match="Malformed signature header"):
|
|
58
|
+
BoomerangSignature.verify(BODY, "bad-header", SECRET)
|
|
59
|
+
|
|
60
|
+
def test_empty_prefix_raises(self):
|
|
61
|
+
with pytest.raises(ValueError):
|
|
62
|
+
BoomerangSignature.verify(BODY, "abcdef1234", SECRET)
|
|
63
|
+
|
|
64
|
+
def test_empty_body(self):
|
|
65
|
+
sig = BoomerangSignature.compute(b"", SECRET)
|
|
66
|
+
assert BoomerangSignature.verify(b"", sig, SECRET) is True
|