|
1 | | -from typing import Mapping, Optional, Union, Iterable, Any, Dict |
| 1 | +from typing import Mapping, Iterable, Optional, Dict, Any, List, Union |
2 | 2 |
|
3 | 3 | Headers = Mapping[str, str] |
| 4 | +QueryParams = Mapping[str, Union[str, Iterable[str]]] |
4 | 5 | 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) |
8 | 6 |
|
| 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()} |
9 | 27 |
|
10 | 28 | 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.""" |
44 | 30 |
|
45 | 31 | def __init__( |
46 | 32 | 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: |
75 | 44 | self.method = method |
76 | | - self.path = path |
77 | | - self.headers = headers |
78 | | - self.body = body |
79 | 45 | 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) |
82 | 50 | 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