Skip to content

Commit 1348bf5

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

File tree

1 file changed

+161
-73
lines changed

1 file changed

+161
-73
lines changed
Lines changed: 161 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,173 @@
1-
from typing import Mapping, Optional, Union, Iterable, Any, Dict
1+
from typing import Mapping, Iterable, Optional, Dict, Any, List, Union
22

33
Headers = Mapping[str, str]
4+
QueryParams = Mapping[str, Union[str, Iterable[str]]]
45
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)
86

7+
class FilePart:
8+
field_name: str
9+
filename: str
10+
content_type: Optional[str]
11+
content: bytes
12+
size: int
13+
14+
def __init__(self, field_name: str, filename: str, content_type: Optional[str], content: bytes, size: int):
15+
self.field_name = field_name
16+
self.filename = filename
17+
self.content_type = content_type
18+
self.content = content
19+
self.size = size
20+
21+
22+
def _multi_to_strlist(obj: Any) -> Dict[str, List[str]]:
23+
getlist = getattr(obj, "getlist", None)
24+
if callable(getlist):
25+
return {k: obj.getlist(k) for k in obj.keys()}
26+
return {k: [obj[k]] for k in obj.keys()}
927

1028
class Request:
11-
"""
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.
24-
"""
25-
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
29+
"""Framework-agnostic request snapshot with raw_body, form, files."""
4430

4531
def __init__(
4632
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-
):
59-
"""
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.
74-
"""
33+
*,
34+
method: str,
35+
url: str,
36+
path: str,
37+
headers: Headers,
38+
query: Mapping[str, List[str]],
39+
cookies: Cookies,
40+
raw_body: Optional[bytes],
41+
form: Mapping[str, List[str]],
42+
files: Mapping[str, List[FilePart]],
43+
) -> None:
7544
self.method = method
76-
self.path = path
77-
self.headers = headers
78-
self.body = body
7945
self.url = url
80-
self.query = query
81-
self.cookies = cookies
46+
self.path = path
47+
self.headers = dict(headers)
48+
self.query = dict(query)
49+
self.cookies = dict(cookies)
8250
self.raw_body = raw_body
83-
self.form = form
84-
self.files = files
85-
self.extensions = extensions
51+
self.form = dict(form)
52+
self.files = dict(files)
53+
54+
# -------- Static mappers (no hard deps imported) --------
55+
@staticmethod
56+
async def from_fastapi(req, *, max_file_bytes: int = 2 * 1024 * 1024) -> "Request":
57+
raw = await req.body()
58+
query = _multi_to_strlist(req.query_params)
59+
cookies = dict(req.cookies)
60+
61+
form_map: Dict[str, List[str]] = {}
62+
files_map: Dict[str, List[FilePart]] = {}
63+
64+
form_data = await req.form()
65+
for key in form_data.keys():
66+
values = form_data.getlist(key)
67+
for v in values:
68+
if hasattr(v, "filename") and hasattr(v, "read"):
69+
content_type = getattr(v, "content_type", None)
70+
data = await v.read()
71+
if len(data) > max_file_bytes:
72+
data = data[:max_file_bytes]
73+
files_map.setdefault(key, []).append(
74+
FilePart(
75+
field_name=key,
76+
filename=v.filename or "",
77+
content_type=content_type,
78+
content=data,
79+
size=len(data)
80+
)
81+
)
82+
else:
83+
form_map.setdefault(key, []).append(str(v))
84+
85+
return Request(
86+
method=req.method,
87+
url=str(req.url),
88+
path=req.url.path,
89+
headers=dict(req.headers),
90+
query=query,
91+
cookies=cookies,
92+
raw_body=raw,
93+
form=form_map,
94+
files=files_map,
95+
)
96+
97+
@staticmethod
98+
def from_django(req, *, max_file_bytes: int = 2 * 1024 * 1024) -> "Request":
99+
url = req.build_absolute_uri()
100+
query = _multi_to_strlist(req.GET)
101+
cookies = dict(req.COOKIES)
102+
103+
form_map = _multi_to_strlist(getattr(req, "POST", {}))
104+
105+
files_map: Dict[str, List[FilePart]] = {}
106+
files_src = getattr(req, "FILES", {})
107+
if hasattr(files_src, "getlist"):
108+
for key in files_src.keys():
109+
for f in files_src.getlist(key):
110+
content_type = getattr(f, "content_type", None)
111+
data = f.read()
112+
if len(data) > max_file_bytes:
113+
data = data[:max_file_bytes]
114+
files_map.setdefault(key, []).append(
115+
FilePart(
116+
field_name=key,
117+
filename=getattr(f, "name", "") or "",
118+
content_type=content_type,
119+
content=data,
120+
size=len(data)
121+
)
122+
)
123+
124+
raw = bytes(getattr(req, "body", b"") or b"")
125+
126+
return Request(
127+
method=req.method,
128+
url=url,
129+
path=req.path,
130+
headers=dict(req.headers),
131+
query=query,
132+
cookies=cookies,
133+
raw_body=raw,
134+
form=form_map,
135+
files=files_map,
136+
)
137+
138+
@staticmethod
139+
def from_flask(req, *, max_file_bytes: int = 2 * 1024 * 1024) -> "Request":
140+
raw = req.get_data(cache=True)
141+
query = _multi_to_strlist(req.args)
142+
cookies = dict(req.cookies)
143+
144+
form_map = _multi_to_strlist(req.form)
145+
146+
files_map: Dict[str, List[FilePart]] = {}
147+
for key in req.files.keys():
148+
for s in req.files.getlist(key):
149+
content_type = getattr(s, "mimetype", None)
150+
data = s.read()
151+
if len(data) > max_file_bytes:
152+
data = data[:max_file_bytes]
153+
files_map.setdefault(key, []).append(
154+
FilePart(
155+
field_name=key,
156+
filename=s.filename or "",
157+
content_type=content_type,
158+
content=data,
159+
size=len(data)
160+
)
161+
)
162+
163+
return Request(
164+
method=req.method,
165+
url=req.url,
166+
path=req.path,
167+
headers=dict(req.headers),
168+
query=query,
169+
cookies=cookies,
170+
raw_body=raw,
171+
form=form_map,
172+
files=files_map,
173+
)

0 commit comments

Comments
 (0)