|
| 1 | +# apimatic_core/adapters/request_adapter.py |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
1 | 5 | import asyncio |
2 | | -from typing import Dict, List, Optional, Union, Any |
| 6 | +from typing import Any, Dict, List, Mapping, Optional, Union |
| 7 | + |
3 | 8 | from http.cookies import SimpleCookie |
4 | | -from apimatic_core_interfaces.http.request import Request |
5 | 9 |
|
| 10 | +from apimatic_core_interfaces.http.request import Request |
6 | 11 | from apimatic_core.adapters.types.django_request_like import DjangoRequestLike |
7 | 12 | from apimatic_core.adapters.types.flask_request_like import FlaskRequestLike |
8 | 13 | from apimatic_core.adapters.types.starlette_request_like import StarletteRequestLike |
9 | 14 |
|
10 | 15 |
|
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 `{}`. |
| 16 | +# ----------------------- |
| 17 | +# Shared utilities |
| 18 | +# ----------------------- |
22 | 19 |
|
23 | | - Returns: |
24 | | - A new dict where each key maps to a list of strings. |
25 | | - """ |
| 20 | +def _as_listdict(obj: Any) -> Dict[str, List[str]]: |
26 | 21 | if not obj: |
27 | 22 | return {} |
28 | 23 | getlist = getattr(obj, "getlist", None) |
29 | 24 | if callable(getlist): |
30 | | - return {k: list(getlist(k)) for k in obj.keys()} |
31 | | - return {k: [obj[k]] for k in obj.keys()} |
| 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()} |
32 | 27 |
|
33 | 28 |
|
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. |
| 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() |
39 | 32 |
|
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 | 33 |
|
149 | | - raise TypeError(f"Unsupported request type: {type(req)!r}") |
| 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")) |
150 | 38 |
|
151 | 39 |
|
152 | | -def _unwrap_local_proxy(obj: Any) -> Any: |
153 | | - """ |
154 | | - Best-effort, dependency-free unwrapping for Flask's `LocalProxy`. |
| 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 | + |
155 | 49 |
|
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. |
| 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 | + } |
161 | 61 |
|
162 | | - Args: |
163 | | - obj: Potentially a `LocalProxy` or any object. |
164 | 62 |
|
165 | | - Returns: |
166 | | - The unwrapped underlying object when possible; otherwise `obj` unchanged. |
| 63 | +def _unwrap_local_proxy(obj: Any) -> Any: |
167 | 64 | """ |
168 | | - getter = getattr(obj, "_get_current_object", None) |
169 | | - if callable(getter): |
| 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): |
170 | 72 | try: |
171 | | - return getter() |
| 73 | + return get_current() |
172 | 74 | 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 | 75 | return obj |
176 | 76 | return obj |
177 | 77 |
|
178 | 78 |
|
179 | | -def to_unified_request_sync( |
180 | | - req: Union[FlaskRequestLike, DjangoRequestLike] |
| 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( |
| 163 | + req: Union[StarletteRequestLike, FlaskRequestLike, DjangoRequestLike] |
181 | 164 | ) -> Request: |
182 | 165 | """ |
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. |
| 166 | + Convert a framework request (Starlette/FastAPI, Flask/Werkzeug, or Django) to a unified snapshot. |
189 | 167 |
|
190 | | - Args: |
191 | | - req: A Flask- or Django-like request object (structurally typed). Passing an |
192 | | - already unified `Request` snapshot is an error. |
| 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}") |
193 | 178 |
|
194 | | - Returns: |
195 | | - Request: The framework-agnostic snapshot. |
196 | 179 |
|
197 | | - Raises: |
198 | | - TypeError: If the provided object is not a supported request shape. |
| 180 | +def to_unified_request_sync( |
| 181 | + req: Union[StarletteRequestLike, FlaskRequestLike, DjangoRequestLike, Any] |
| 182 | +) -> Request: |
| 183 | + """ |
| 184 | + Synchronous wrapper around `to_unified_request` with LocalProxy unwrapping. |
199 | 185 | """ |
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)) |
| 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(unwrapped)) |
0 commit comments