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.
@@ -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,3 @@
1
+ # boomerang-client
2
+
3
+ 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)
@@ -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