Skip to content

Commit db79796

Browse files
committed
revision: adds structural typing and adapter for requests
1 parent e803faf commit db79796

6 files changed

Lines changed: 177 additions & 2 deletions

File tree

apimatic_core/adapters/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
__all__ = [
2+
'request_adapter',
3+
'flask_like',
4+
'django_like',
5+
'starlette_like'
6+
]
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: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import asyncio
2+
from typing import Dict, List, Optional, Union, Any
3+
from http.cookies import SimpleCookie
4+
from apimatic_core_interfaces.http.request import Request
5+
6+
from apimatic_core.adapters.django_like import DjangoRequestLike
7+
from apimatic_core.adapters.flask_like import FlaskRequestLike
8+
from apimatic_core.adapters.starlette_like import StarletteRequestLike
9+
10+
11+
def _as_listdict(obj: Any) -> Dict[str, List[str]]:
12+
"""MultiDict/QueryDict → Dict[str, List[str]]; Mapping[str,str] → {k:[v]}."""
13+
if not obj:
14+
return {}
15+
getlist = getattr(obj, "getlist", None)
16+
if callable(getlist):
17+
return {k: list(getlist(k)) for k in obj.keys()}
18+
return {k: [obj[k]] for k in obj.keys()}
19+
20+
async def to_unified_request(req: Union[StarletteRequestLike, FlaskRequestLike, DjangoRequestLike]) -> Request:
21+
# --- Starlette/FastAPI ---
22+
if isinstance(req, StarletteRequestLike):
23+
headers = dict(req.headers)
24+
raw = await req.body()
25+
query = _as_listdict(req.query_params)
26+
cookies = dict(req.cookies)
27+
url_str = str(req.url)
28+
path = req.url.path
29+
ct = (headers.get("content-type") or headers.get("Content-Type") or "").lower()
30+
form: Dict[str, List[str]] = {}
31+
if ct.startswith(("multipart/form-data", "application/x-www-form-urlencoded")):
32+
form_data = await req.form()
33+
for k in form_data.keys():
34+
for v in form_data.getlist(k):
35+
if not (hasattr(v, "filename") and hasattr(v, "read")):
36+
form.setdefault(k, []).append(str(v))
37+
return Request(method=req.method, path=path, url=url_str, headers=headers,
38+
raw_body=raw, query=query, cookies=cookies, form=form)
39+
40+
# --- Flask ---
41+
if isinstance(req, FlaskRequestLike):
42+
headers = dict(req.headers)
43+
url_str: Optional[str] = getattr(req, "url", None)
44+
path: str = req.path
45+
raw: bytes = req.get_data(cache=True)
46+
query = _as_listdict(req.args)
47+
cookies = dict(req.cookies)
48+
# best-effort cookie header fallback if the jar is empty
49+
if not cookies:
50+
cookie_header = headers.get("Cookie") or headers.get("cookie")
51+
if cookie_header:
52+
jar = SimpleCookie(); jar.load(cookie_header)
53+
cookies = {k: morsel.value for k, morsel in jar.items()}
54+
form = _as_listdict(req.form)
55+
return Request(method=req.method, path=path, url=url_str, headers=headers,
56+
raw_body=raw, query=query, cookies=cookies, form=form)
57+
58+
# --- Django ---
59+
if isinstance(req, DjangoRequestLike):
60+
headers = dict(getattr(req, "headers", {}) or {})
61+
# fallback for very old Django: META → headers
62+
if not headers:
63+
meta = getattr(req, "META", {}) or {}
64+
headers = {k[5:].replace("_", "-"): str(v) for k, v in meta.items() if k.startswith("HTTP_")}
65+
url_str = req.build_absolute_uri()
66+
path = req.path
67+
raw = bytes(getattr(req, "body", b"") or b"")
68+
query = _as_listdict(getattr(req, "GET", {}))
69+
cookies = dict(getattr(req, "COOKIES", {}) or {})
70+
form = _as_listdict(getattr(req, "POST", {}))
71+
return Request(method=req.method, path=path, url=url_str, headers=headers,
72+
raw_body=raw, query=query, cookies=cookies, form=form)
73+
74+
raise TypeError(f"Unsupported request type: {type(req)!r}")
75+
76+
def _unwrap_local_proxy(obj):
77+
"""
78+
Best-effort, dependency-free unwrapping for Flask's LocalProxy.
79+
80+
We intentionally DO NOT import `werkzeug.local.LocalProxy` to keep this adapter
81+
framework-agnostic and avoid a hard dependency on Werkzeug/Flask internals.
82+
83+
Instead, we use *duck typing*: if the object exposes `_get_current_object()`
84+
(the LocalProxy API), we call it to retrieve the real underlying request.
85+
- If the object is not a LocalProxy, or unwrapping fails, we just return `obj`.
86+
"""
87+
getter = getattr(obj, "_get_current_object", None)
88+
if callable(getter):
89+
try:
90+
return getter()
91+
except Exception:
92+
# If unwrapping fails for any reason, fall back to the original object.
93+
# The async core will still attempt structural dispatch via Protocols.
94+
return obj
95+
return obj
96+
97+
def to_unified_request_sync(
98+
req: Union[FlaskRequestLike, DjangoRequestLike]
99+
) -> Request:
100+
"""
101+
Sync wrapper around the async `to_unified_request(...)` for use in
102+
synchronous frameworks (Flask/Django) or sync code paths.
103+
104+
Key points:
105+
- Accepts any object that *structurally* satisfies Flask/Django request Protocols.
106+
- Transparently unwraps Flask's LocalProxy (without importing Werkzeug) using
107+
duck-typing via `_get_current_object()`.
108+
- Reuses the running event loop if present, otherwise creates one (common in
109+
sync WSGI servers).
110+
"""
111+
# 1) Unwrap LocalProxy-like objects (no werkzeug import; duck-typing)
112+
req = _unwrap_local_proxy(req)
113+
114+
# 3) Bridge sync -> async: run the async core in (or with) an event loop.
115+
try:
116+
loop = asyncio.get_event_loop()
117+
except RuntimeError:
118+
# No running loop in this thread (typical in WSGI). Create one.
119+
loop = asyncio.new_event_loop()
120+
asyncio.set_event_loop(loop)
121+
122+
# 4) Delegate to the single async entrypoint that handles Starlette/Flask/Django
123+
# via structural typing (Protocols).
124+
return loop.run_until_complete(to_unified_request(req))
125+
126+
async def from_starlette(req: StarletteRequestLike) -> Request:
127+
return await to_unified_request(req)
128+
129+
def from_flask(req: FlaskRequestLike) -> Request:
130+
return to_unified_request_sync(req)
131+
132+
def from_django(req: DjangoRequestLike) -> Request:
133+
return to_unified_request_sync(req)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from typing import Any, Mapping, Coroutine
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 # __str__ -> URL; has .path: str
11+
def body(self) -> Coroutine[Any, Any, bytes]: ...
12+
def form(self) -> Coroutine[Any, Any, Any]: ...

tests/apimatic_core/security/signature_verification/test_hmac_signature_verifier.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ def _get(name: str, default=None):
3434
cookies=dict(_get("cookies", {}) or {}),
3535
raw_body=_get("raw_body", None),
3636
form=dict(_get("form", {}) or {}),
37-
files=dict(_get("files", {}) or {}),
3837
)
3938
# Apply overrides
4039
payload.update(overrides)
@@ -153,7 +152,6 @@ def req_base(self) -> Request:
153152
cookies={}, # Mapping[str, str]
154153
raw_body=b'{"event":{"id":"evt_1"},"payload":{"checksum":"abc"}}',
155154
form={}, # Mapping[str, List[str]]
156-
files={}, # Mapping[str, List[FilePart]]
157155
)
158156

159157
@pytest.fixture

0 commit comments

Comments
 (0)