Skip to content

Commit 98621d9

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

10 files changed

Lines changed: 601 additions & 2 deletions

File tree

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: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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.types.django_request_like import DjangoRequestLike
7+
from apimatic_core.adapters.types.flask_request_like import FlaskRequestLike
8+
from apimatic_core.adapters.types.starlette_request_like import StarletteRequestLike
9+
10+
11+
def _as_listdict(obj: Any) -> Dict[str, List[str]]:
12+
"""
13+
Normalize a query/form-like mapping to a plain `Dict[str, List[str]]`.
14+
15+
Supports:
16+
- Objects exposing a `getlist(key)` API (e.g., Django `QueryDict`,
17+
Werkzeug `MultiDict`) → copies each list.
18+
- Plain mappings (`Mapping[str, str]`) → wraps scalar values in single-item lists.
19+
20+
Args:
21+
obj: A mapping or MultiDict/QueryDict-like object. Falsy values return `{}`.
22+
23+
Returns:
24+
A new dict where each key maps to a list of strings.
25+
"""
26+
if not obj:
27+
return {}
28+
getlist = getattr(obj, "getlist", None)
29+
if callable(getlist):
30+
return {k: list(getlist(k)) for k in obj.keys()}
31+
return {k: [obj[k]] for k in obj.keys()}
32+
33+
34+
async def to_unified_request(
35+
req: Union[StarletteRequestLike, FlaskRequestLike, DjangoRequestLike]
36+
) -> Request:
37+
"""
38+
Convert a framework request (Starlette/FastAPI, Flask/Werkzeug, or Django) to a unified snapshot.
39+
40+
This function uses structural typing (Protocols) to detect the request "shape"
41+
at runtime and extracts a compact, immutable snapshot that excludes file uploads.
42+
43+
Extraction rules:
44+
• Method/path/url: copied verbatim (with Starlette `url` stringified).
45+
• Headers: copied into a plain `dict` (case preserved as-provided by the framework).
46+
• Raw body: captured as bytes (Starlette: `await req.body()`; Flask: `get_data(cache=True)`;
47+
Django: `req.body`).
48+
• Query/form: normalized to `Dict[str, List[str]]` via `_as_listdict(...)`.
49+
For Starlette, form parsing only occurs for `multipart/form-data` or
50+
`application/x-www-form-urlencoded` content types, and file parts are ignored.
51+
• Cookies: copied to a `dict`. Flask additionally falls back to parsing the
52+
`Cookie` header if `req.cookies` is empty.
53+
• Django headers: if `req.headers` is missing/empty (very old Django),
54+
a best-effort fallback uses `req.META["HTTP_*"]`.
55+
56+
Args:
57+
req: A request object structurally compatible with one of the supported
58+
frameworks' request shapes.
59+
60+
Returns:
61+
Request: The framework-agnostic snapshot.
62+
63+
Raises:
64+
TypeError: If `req` does not match any supported Protocol.
65+
"""
66+
# --- Starlette/FastAPI ---
67+
if isinstance(req, StarletteRequestLike):
68+
headers = dict(req.headers)
69+
raw = await req.body()
70+
query = _as_listdict(req.query_params)
71+
cookies = dict(req.cookies)
72+
url_str = str(req.url)
73+
path = req.url.path
74+
ct = (headers.get("content-type") or headers.get("Content-Type") or "").lower()
75+
form: Dict[str, List[str]] = {}
76+
if ct.startswith(("multipart/form-data", "application/x-www-form-urlencoded")):
77+
form_data = await req.form()
78+
for k in form_data.keys():
79+
for v in form_data.getlist(k):
80+
# Ignore file-like values (UploadFile or similar)
81+
if not (hasattr(v, "filename") and hasattr(v, "read")):
82+
form.setdefault(k, []).append(str(v))
83+
return Request(
84+
method=req.method,
85+
path=path,
86+
url=url_str,
87+
headers=headers,
88+
raw_body=raw,
89+
query=query,
90+
cookies=cookies,
91+
form=form,
92+
)
93+
94+
# --- Flask ---
95+
if isinstance(req, FlaskRequestLike):
96+
headers = dict(req.headers)
97+
url_str: Optional[str] = getattr(req, "url", None)
98+
path: str = req.path
99+
raw: bytes = req.get_data(cache=True)
100+
query = _as_listdict(req.args)
101+
cookies = dict(req.cookies)
102+
# Best-effort cookie header fallback if the jar is empty
103+
if not cookies:
104+
cookie_header = headers.get("Cookie") or headers.get("cookie")
105+
if cookie_header:
106+
jar = SimpleCookie()
107+
jar.load(cookie_header)
108+
cookies = {k: morsel.value for k, morsel in jar.items()}
109+
form = _as_listdict(req.form)
110+
return Request(
111+
method=req.method,
112+
path=path,
113+
url=url_str,
114+
headers=headers,
115+
raw_body=raw,
116+
query=query,
117+
cookies=cookies,
118+
form=form,
119+
)
120+
121+
# --- Django ---
122+
if isinstance(req, DjangoRequestLike):
123+
headers = dict(getattr(req, "headers", {}) or {})
124+
# Fallback for very old Django: META → headers
125+
if not headers:
126+
meta = getattr(req, "META", {}) or {}
127+
headers = {
128+
k[5:].replace("_", "-"): str(v)
129+
for k, v in meta.items()
130+
if k.startswith("HTTP_")
131+
}
132+
url_str = req.build_absolute_uri()
133+
path = req.path
134+
raw = bytes(getattr(req, "body", b"") or b"")
135+
query = _as_listdict(getattr(req, "GET", {}))
136+
cookies = dict(getattr(req, "COOKIES", {}) or {})
137+
form = _as_listdict(getattr(req, "POST", {}))
138+
return Request(
139+
method=req.method,
140+
path=path,
141+
url=url_str,
142+
headers=headers,
143+
raw_body=raw,
144+
query=query,
145+
cookies=cookies,
146+
form=form,
147+
)
148+
149+
raise TypeError(f"Unsupported request type: {type(req)!r}")
150+
151+
152+
def _unwrap_local_proxy(obj: Any) -> Any:
153+
"""
154+
Best-effort, dependency-free unwrapping for Flask's `LocalProxy`.
155+
156+
We intentionally do not import `werkzeug.local.LocalProxy` to keep this adapter
157+
framework-agnostic. Instead, we use duck typing: if the object exposes
158+
`_get_current_object()` (the LocalProxy API), we call it to retrieve the real
159+
underlying request. If the object is not a LocalProxy, or unwrapping fails,
160+
the original object is returned.
161+
162+
Args:
163+
obj: Potentially a `LocalProxy` or any object.
164+
165+
Returns:
166+
The unwrapped underlying object when possible; otherwise `obj` unchanged.
167+
"""
168+
getter = getattr(obj, "_get_current_object", None)
169+
if callable(getter):
170+
try:
171+
return getter()
172+
except Exception:
173+
# If unwrapping fails for any reason, fall back to the original object.
174+
# The async core will still attempt structural dispatch via Protocols.
175+
return obj
176+
return obj
177+
178+
179+
def to_unified_request_sync(
180+
req: Union[FlaskRequestLike, DjangoRequestLike]
181+
) -> Request:
182+
"""
183+
Synchronous wrapper around `to_unified_request(...)` for WSGI-style apps.
184+
185+
This bridges sync Flask/Django views to the async core by:
186+
• Unwrapping Flask's `LocalProxy` via duck typing (no Werkzeug import).
187+
• Reusing the running event loop when present; otherwise creating one.
188+
• Delegating to the async adapter which handles structural dispatch.
189+
190+
Args:
191+
req: A Flask- or Django-like request object (structurally typed). Passing an
192+
already unified `Request` snapshot is an error.
193+
194+
Returns:
195+
Request: The framework-agnostic snapshot.
196+
197+
Raises:
198+
TypeError: If the provided object is not a supported request shape.
199+
"""
200+
# 1) Unwrap LocalProxy-like objects (no werkzeug import; duck-typing)
201+
req = _unwrap_local_proxy(req)
202+
203+
# 3) Bridge sync -> async: run the async core in (or with) an event loop.
204+
try:
205+
loop = asyncio.get_event_loop()
206+
except RuntimeError:
207+
# No running loop in this thread (typical in WSGI). Create one.
208+
loop = asyncio.new_event_loop()
209+
asyncio.set_event_loop(loop)
210+
211+
# 4) Delegate to the single async entrypoint that handles Starlette/Flask/Django
212+
# via structural typing (Protocols).
213+
return loop.run_until_complete(to_unified_request(req))
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/test.py

Whitespace-only changes.

tests/apimatic_core/adapters/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)