Skip to content

Commit 5ab10c9

Browse files
committed
refactored the common request interface and added factory method for the mapping
1 parent 5c5c0ed commit 5ab10c9

File tree

1 file changed

+202
-76
lines changed

1 file changed

+202
-76
lines changed
Lines changed: 202 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,211 @@
1-
from typing import Mapping, Optional, Union, Iterable, Any, Dict
1+
# request_adapter.py (Python 3.7+)
2+
from dataclasses import dataclass, field
3+
from typing import Any, Dict, List, Optional
24

3-
Headers = Mapping[str, str]
4-
Cookies = Mapping[str, str]
5-
QueryParams = Mapping[str, Any]
6-
FormData = Mapping[str, Any]
7-
Files = Mapping[str, Any] # framework-agnostic placeholder (e.g., Starlette UploadFile)
85

6+
@dataclass(frozen=True)
7+
class RequestFile:
8+
"""Framework-agnostic snapshot of an uploaded file (full content)."""
9+
filename: str
10+
content_type: Optional[str]
11+
content: bytes # full file bytes
912

13+
14+
@dataclass(frozen=True)
1015
class Request:
1116
"""
12-
Framework-agnostic HTTP request snapshot used by verifiers and the webhook manager.
13-
14-
This class captures all important parts of an HTTP request in a way that does not depend
15-
on any specific web framework. It is particularly useful for signature verification and
16-
webhook processing.
17-
18-
Notes:
19-
- `body` contains the decoded textual representation of the body (e.g., a JSON string).
20-
This should be kept exactly as received.
21-
- `raw_body` contains the raw byte stream if available. Use this for cryptographic
22-
verification when signatures depend on raw payloads.
23-
- Header names should be treated case-insensitively when accessed.
17+
Compact, framework-agnostic HTTP request snapshot.
18+
19+
Fields:
20+
- method, path, url
21+
- headers: Dict[str, str] (copied; later mutations on the original won't leak)
22+
- raw_body: bytes (wire bytes; frameworks cache safely)
23+
- query/form: Dict[str, List[str]> (multi-value safe)
24+
- cookies: Dict[str, str]
25+
- files: Dict[str, List[RequestFile]> (streams read fully & rewound)
2426
"""
27+
method: str
28+
path: str
29+
url: Optional[str]
30+
headers: Dict[str, str]
31+
raw_body: bytes
32+
query: Dict[str, List[str]] = field(default_factory=dict)
33+
cookies: Dict[str, str] = field(default_factory=dict)
34+
form: Dict[str, List[str]] = field(default_factory=dict)
35+
files: Dict[str, List[RequestFile]] = field(default_factory=dict)
36+
37+
# ---------- helpers ----------
38+
39+
@staticmethod
40+
def _as_listdict(obj: Any) -> Dict[str, List[str]]:
41+
"""MultiDict/QueryDict → Dict[str, List[str]]; Mapping[str,str] → {k:[v]}."""
42+
if not obj:
43+
return {}
44+
getlist = getattr(obj, "getlist", None)
45+
if callable(getlist):
46+
return {k: list(getlist(k)) for k in obj.keys()}
47+
return {k: [obj[k]] for k in obj.keys()}
48+
49+
# ---------- factories (non-destructive) ----------
50+
51+
@staticmethod
52+
async def from_fastapi_request(req, *, max_file_bytes: Optional[int] = None) -> "Request":
53+
"""
54+
Build from fastapi.Request / starlette.requests.Request.
55+
56+
- raw body via await req.body() (Starlette caches; non-destructive)
57+
- parse form only for form/multipart content types
58+
- read UploadFile content **fully** (or up to max_file_bytes if provided) and rewind stream
59+
- copy mappings so later mutations on the original request won't leak
60+
"""
61+
headers = dict(req.headers)
62+
raw = await req.body()
63+
query = Request._as_listdict(req.query_params)
64+
cookies = dict(req.cookies)
65+
url_str = str(req.url)
66+
path = req.url.path
67+
68+
ct = (headers.get("content-type") or headers.get("Content-Type") or "").lower()
69+
parse_form = ct.startswith(("multipart/form-data", "application/x-www-form-urlencoded"))
70+
71+
form: Dict[str, List[str]] = {}
72+
files: Dict[str, List[RequestFile]] = {}
2573

26-
# Required fields
27-
headers: Optional[Headers] = None
28-
method: Optional[str] = None
29-
path: Optional[str] = None
30-
body: Optional[str] = None
31-
32-
# Common metadata
33-
url: Optional[str] = None
34-
query: Optional[QueryParams] = None
35-
cookies: Optional[Cookies] = None
36-
37-
# Optional request bodies
38-
raw_body: Optional[bytes] = None
39-
form: Optional[FormData] = None
40-
files: Optional[Files] = None
41-
42-
# Arbitrary extensions
43-
extensions: Optional[Dict[str, Any]] = None
44-
45-
def __init__(
46-
self,
47-
method: Optional[str] = None,
48-
path: Optional[str] = None,
49-
headers: Optional[Headers] = None,
50-
body: Optional[str] = None,
51-
url: Optional[str] = None,
52-
query: Optional[QueryParams] = None,
53-
cookies: Optional[Cookies] = None,
54-
raw_body: Optional[bytes] = None,
55-
form: Optional[FormData] = None,
56-
files: Optional[Files] = None,
57-
extensions: Optional[Dict[str, Any]] = None,
58-
):
74+
if parse_form:
75+
formdata = await req.form()
76+
# text fields
77+
for k in formdata.keys():
78+
for v in formdata.getlist(k):
79+
if not (hasattr(v, "filename") and hasattr(v, "read")):
80+
form.setdefault(k, []).append(str(v))
81+
# files (rewind after read)
82+
for k in formdata.keys():
83+
for v in formdata.getlist(k):
84+
if hasattr(v, "filename") and hasattr(v, "read"):
85+
fobj = getattr(v, "file", None)
86+
# remember current cursor, read all, then rewind
87+
try:
88+
pos = fobj.tell() if fobj else 0
89+
except Exception:
90+
pos = 0
91+
data = await v.read() # FULL READ
92+
if max_file_bytes is not None and len(data) > max_file_bytes:
93+
data = data[:max_file_bytes]
94+
try:
95+
if fobj:
96+
fobj.seek(pos)
97+
except Exception:
98+
pass
99+
files.setdefault(k, []).append(
100+
RequestFile(v.filename or "", getattr(v, "content_type", None), data)
101+
)
102+
103+
return Request(
104+
method=req.method,
105+
path=path,
106+
url=url_str,
107+
headers=headers,
108+
raw_body=raw,
109+
query=query,
110+
cookies=cookies,
111+
form=form,
112+
files=files,
113+
)
114+
115+
@staticmethod
116+
def from_django_request(req, *, max_file_bytes: Optional[int] = None) -> "Request":
59117
"""
60-
Initialize a new request snapshot.
61-
62-
Args:
63-
method: HTTP method of the request (e.g., "GET", "POST").
64-
path: URL path of the request, excluding query parameters.
65-
headers: Mapping of header keys to values (case-insensitive).
66-
body: Decoded body content as a string, such as a JSON or XML string.
67-
url: Full request URL if available.
68-
query: Mapping of query parameter keys to single or multiple values.
69-
cookies: Mapping of cookie keys to values.
70-
raw_body: Raw bytes of the request body, for signature verification.
71-
form: Parsed form data, if the request contains form fields.
72-
files: Uploaded files associated with the request.
73-
extensions: Arbitrary framework-specific or user-defined metadata.
118+
Build from django.http.HttpRequest.
119+
120+
- uses req.body (cached bytes; non-destructive)
121+
- text fields from req.POST; files from req.FILES (read FULL & rewind)
122+
- copies mappings to avoid leaking later mutations
74123
"""
75-
self.method = method
76-
self.path = path
77-
self.headers = headers
78-
self.body = body
79-
self.url = url
80-
self.query = query
81-
self.cookies = cookies
82-
self.raw_body = raw_body
83-
self.form = form
84-
self.files = files
85-
self.extensions = extensions
124+
headers = dict(getattr(req, "headers", {}) or {})
125+
url_str = req.build_absolute_uri()
126+
path = req.path
127+
raw = bytes(getattr(req, "body", b"") or b"")
128+
query = Request._as_listdict(getattr(req, "GET", {}))
129+
cookies = dict(getattr(req, "COOKIES", {}) or {})
130+
131+
form = Request._as_listdict(getattr(req, "POST", {}))
132+
files: Dict[str, List[RequestFile]] = {}
133+
files_src = getattr(req, "FILES", None)
134+
if files_src and hasattr(files_src, "getlist"):
135+
for k in files_src.keys():
136+
for f in files_src.getlist(k):
137+
try:
138+
pos = f.tell()
139+
except Exception:
140+
pos = 0
141+
data = f.read() # FULL READ
142+
if max_file_bytes is not None and len(data) > max_file_bytes:
143+
data = data[:max_file_bytes]
144+
try:
145+
f.seek(pos)
146+
except Exception:
147+
pass
148+
files.setdefault(k, []).append(
149+
RequestFile(getattr(f, "name", "") or "", getattr(f, "content_type", None), data)
150+
)
151+
152+
return Request(
153+
method=req.method,
154+
path=path,
155+
url=url_str,
156+
headers=headers,
157+
raw_body=raw,
158+
query=query,
159+
cookies=cookies,
160+
form=form,
161+
files=files,
162+
)
163+
164+
@staticmethod
165+
def from_flask_request(req, *, max_file_bytes: Optional[int] = None) -> "Request":
166+
"""
167+
Build from flask.Request (Werkzeug).
168+
169+
- uses req.get_data(cache=True) (non-destructive)
170+
- text fields from req.form; files from req.files (read FULL & rewind)
171+
- copies mappings to avoid leaking later mutations
172+
"""
173+
headers = dict(req.headers)
174+
url_str = getattr(req, "url", None)
175+
path = req.path
176+
raw = req.get_data(cache=True)
177+
query = Request._as_listdict(req.args)
178+
cookies = dict(req.cookies)
179+
180+
form = Request._as_listdict(req.form)
181+
files: Dict[str, List[RequestFile]] = {}
182+
for k in req.files.keys():
183+
for s in req.files.getlist(k):
184+
stream = getattr(s, "stream", None)
185+
try:
186+
pos = stream.tell() if stream else 0
187+
except Exception:
188+
pos = 0
189+
data = s.read() # FULL READ
190+
if max_file_bytes is not None and len(data) > max_file_bytes:
191+
data = data[:max_file_bytes]
192+
try:
193+
if stream:
194+
stream.seek(pos)
195+
except Exception:
196+
pass
197+
files.setdefault(k, []).append(
198+
RequestFile(s.filename or "", getattr(s, "mimetype", None), data)
199+
)
200+
201+
return Request(
202+
method=req.method,
203+
path=path,
204+
url=url_str,
205+
headers=headers,
206+
raw_body=raw,
207+
query=query,
208+
cookies=cookies,
209+
form=form,
210+
files=files,
211+
)

0 commit comments

Comments
 (0)