Skip to content

Commit b24fb51

Browse files
feat(webhooks & callbacks): add contracts for generic HMAC implementation (#105)
This commit adds comprehensive webhook and callback support to the Python core library by introducing an HMAC signature verification system. The implementation provides a framework-agnostic approach to handling webhook events with secure signature verification capabilities. - Implements configurable HMAC signature verification with multiple hash algorithms and encoding options - Adds comprehensive test coverage for all new components, including edge cases and error handling
1 parent 9b4407e commit b24fb51

20 files changed

Lines changed: 1265 additions & 82 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,4 @@ cython_debug/
162162
# Visual Studio Code
163163
.vscode/
164164
.qodo
165+
*~

README.md

Lines changed: 137 additions & 77 deletions
Large diffs are not rendered by default.

apimatic_core/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@
1212
'logger',
1313
'exceptions',
1414
'constants',
15-
'pagination'
15+
'pagination',
16+
'security'
1617
]

apimatic_core/adapters/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
__all__ = [
2+
'request_adapter',
3+
'types',
4+
]
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# apimatic_core/adapters/request_adapter.py
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
from typing import Any, Dict, List, Mapping, Optional, Union
7+
8+
from http.cookies import SimpleCookie
9+
10+
from apimatic_core_interfaces.http.request import Request
11+
from apimatic_core.adapters.types.django_request_like import DjangoRequestLike
12+
from apimatic_core.adapters.types.flask_request_like import FlaskRequestLike
13+
from apimatic_core.adapters.types.starlette_request_like import StarletteRequestLike
14+
15+
16+
# -----------------------
17+
# Shared utilities
18+
# -----------------------
19+
20+
def _as_listdict(obj: Any) -> Dict[str, List[str]]:
21+
if not obj:
22+
return {}
23+
getlist = getattr(obj, "getlist", None)
24+
if callable(getlist):
25+
return {str(k): list(getlist(k)) for k in obj.keys()}
26+
return {str(k): [str(v)] for k, v in dict(obj).items()}
27+
28+
29+
def _content_type(headers: Mapping[str, str]) -> str:
30+
"""Return lower-cased Content-Type value or empty string."""
31+
return (headers.get("content-type") or headers.get("Content-Type") or "").lower()
32+
33+
34+
def _is_urlencoded_or_multipart(headers: Mapping[str, str]) -> bool:
35+
"""Check if body is form-like (urlencoded/multipart)."""
36+
ct = _content_type(headers)
37+
return ct.startswith(("multipart/form-data", "application/x-www-form-urlencoded"))
38+
39+
40+
def _cookies_from_header(headers: Mapping[str, str]) -> Dict[str, str]:
41+
"""Parse Cookie header into a dict, returns {} if absent/empty."""
42+
cookie_header = headers.get("Cookie") or headers.get("cookie")
43+
if not cookie_header:
44+
return {}
45+
jar = SimpleCookie()
46+
jar.load(cookie_header)
47+
return {k: morsel.value for k, morsel in jar.items()}
48+
49+
50+
def _django_headers_fallback(req: DjangoRequestLike) -> Dict[str, str]:
51+
"""
52+
Fallback for very old Django where `request.headers` is missing/empty.
53+
Builds headers from META['HTTP_*'] entries.
54+
"""
55+
meta = getattr(req, "META", {}) or {}
56+
return {
57+
k[5:].replace("_", "-"): str(v)
58+
for k, v in meta.items()
59+
if isinstance(k, str) and k.startswith("HTTP_")
60+
}
61+
62+
63+
def _unwrap_local_proxy(obj: Any) -> Any:
64+
"""
65+
Best-effort unwrapping for LocalProxy-like objects (e.g., Werkzeug/Flask).
66+
If `_get_current_object` exists and works, return the underlying object.
67+
If calling it raises, swallow and return the original object.
68+
If it doesn't exist, return the original object.
69+
"""
70+
get_current = getattr(obj, "_get_current_object", None)
71+
if callable(get_current):
72+
try:
73+
return get_current()
74+
except Exception:
75+
return obj
76+
return obj
77+
78+
79+
# -----------------------
80+
# Per-framework converters
81+
# -----------------------
82+
83+
async def _from_starlette(req: StarletteRequestLike) -> Request:
84+
headers = dict(req.headers)
85+
raw = await req.body()
86+
query = _as_listdict(req.query_params)
87+
cookies = dict(req.cookies)
88+
url_str = str(req.url)
89+
path = req.url.path
90+
91+
form: Dict[str, List[str]] = {}
92+
if _is_urlencoded_or_multipart(headers):
93+
form_data = await req.form()
94+
for k in form_data.keys():
95+
# Filter out file-like parts (e.g., UploadFile: has filename & read)
96+
values = [
97+
str(v)
98+
for v in form_data.getlist(k)
99+
if not (hasattr(v, "filename") and hasattr(v, "read"))
100+
]
101+
if values:
102+
form[k] = values
103+
104+
return Request(
105+
method=req.method,
106+
path=path,
107+
url=url_str,
108+
headers=headers,
109+
raw_body=raw,
110+
query=query,
111+
cookies=cookies,
112+
form=form,
113+
)
114+
115+
116+
def _from_flask(req: FlaskRequestLike) -> Request:
117+
headers = dict(req.headers)
118+
url_str: Optional[str] = getattr(req, "url", None)
119+
path: str = req.path
120+
raw: bytes = req.get_data(cache=True)
121+
query = _as_listdict(req.args)
122+
cookies = dict(req.cookies) or _cookies_from_header(headers)
123+
form = _as_listdict(req.form)
124+
125+
return Request(
126+
method=req.method,
127+
path=path,
128+
url=url_str,
129+
headers=headers,
130+
raw_body=raw,
131+
query=query,
132+
cookies=cookies,
133+
form=form,
134+
)
135+
136+
137+
def _from_django(req: DjangoRequestLike) -> Request:
138+
headers = dict(getattr(req, "headers", {}) or {}) or _django_headers_fallback(req)
139+
url_str = req.build_absolute_uri()
140+
path = req.path
141+
raw = bytes(getattr(req, "body", b"") or b"")
142+
query = _as_listdict(getattr(req, "GET", {}))
143+
cookies = dict(getattr(req, "COOKIES", {}) or {})
144+
form = _as_listdict(getattr(req, "POST", {}))
145+
146+
return Request(
147+
method=req.method,
148+
path=path,
149+
url=url_str,
150+
headers=headers,
151+
raw_body=raw,
152+
query=query,
153+
cookies=cookies,
154+
form=form,
155+
)
156+
157+
158+
# -----------------------
159+
# Public API
160+
# -----------------------
161+
162+
async def to_unified_request_async(
163+
req: Union[StarletteRequestLike, FlaskRequestLike, DjangoRequestLike]
164+
) -> Request:
165+
"""
166+
Convert a framework request (Starlette/FastAPI, Flask/Werkzeug, or Django) to a unified snapshot.
167+
168+
Uses structural typing to detect the request “shape” and extracts an immutable snapshot
169+
(no file uploads). See per-framework helpers for exact extraction rules.
170+
"""
171+
if isinstance(req, StarletteRequestLike):
172+
return await _from_starlette(req)
173+
if isinstance(req, FlaskRequestLike):
174+
return _from_flask(req)
175+
if isinstance(req, DjangoRequestLike):
176+
return _from_django(req)
177+
raise TypeError(f"Unsupported request type: {type(req)!r}")
178+
179+
180+
def to_unified_request(
181+
req: Union[StarletteRequestLike, FlaskRequestLike, DjangoRequestLike, Any]
182+
) -> Request:
183+
"""
184+
Synchronous wrapper around `to_unified_request` with LocalProxy unwrapping.
185+
"""
186+
unwrapped = _unwrap_local_proxy(req)
187+
# We expect to be called from sync code; create and run a fresh loop.
188+
return asyncio.run(to_unified_request_async(unwrapped))
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
__all__ = [
2+
'django_request_like.py',
3+
'flask_request_like.py',
4+
'starlette_request_like.py'
5+
]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from typing import Any, Mapping
2+
from typing_extensions import Protocol, runtime_checkable
3+
4+
@runtime_checkable
5+
class DjangoRequestLike(Protocol):
6+
method: str
7+
headers: Mapping[str, str]
8+
COOKIES: Mapping[str, str]
9+
GET: Mapping[str, Any]
10+
POST: Mapping[str, Any]
11+
path: str
12+
body: bytes
13+
def build_absolute_uri(self) -> str: ...
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from typing import Any, Mapping
2+
from typing_extensions import Protocol, runtime_checkable
3+
4+
@runtime_checkable
5+
class FlaskRequestLike(Protocol):
6+
method: str
7+
headers: Mapping[str, str]
8+
cookies: Mapping[str, str]
9+
args: Mapping[str, Any]
10+
url: str
11+
path: str
12+
def get_data(self, cache: bool = ...) -> bytes: ...
13+
form: Mapping[str, Any]
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from typing import Any, Mapping, Awaitable
2+
from typing_extensions import Protocol, runtime_checkable
3+
4+
@runtime_checkable
5+
class StarletteRequestLike(Protocol):
6+
method: str
7+
headers: Mapping[str, str]
8+
cookies: Mapping[str, str]
9+
query_params: Mapping[str, Any]
10+
url: Any
11+
def body(self) -> Awaitable[bytes]: ...
12+
def form(self) -> Awaitable[Any]: ...

apimatic_core/security/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
__all__=[
2+
'signature_verifiers'
3+
]

0 commit comments

Comments
 (0)