orbit-web-framework 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- orbit/__init__.py +16 -0
- orbit/core/__init__.py +1 -0
- orbit/core/application.py +289 -0
- orbit/core/middleware.py +236 -0
- orbit/core/request.py +126 -0
- orbit/core/response.py +264 -0
- orbit/core/router.py +183 -0
- orbit/http/__init__.py +1 -0
- orbit/http/parser.py +176 -0
- orbit/http/server.py +387 -0
- orbit/static/__init__.py +1 -0
- orbit/static/handler.py +168 -0
- orbit/template/__init__.py +1 -0
- orbit/template/engine.py +239 -0
- orbit/testing/__init__.py +1 -0
- orbit/testing/client.py +120 -0
- orbit_web_framework-1.0.2.dist-info/METADATA +480 -0
- orbit_web_framework-1.0.2.dist-info/RECORD +21 -0
- orbit_web_framework-1.0.2.dist-info/WHEEL +5 -0
- orbit_web_framework-1.0.2.dist-info/licenses/LICENSE +21 -0
- orbit_web_framework-1.0.2.dist-info/top_level.txt +1 -0
orbit/core/response.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Response Builder
|
|
3
|
+
Manual HTTP response construction
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import Dict, Optional, Union, Iterator
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Response:
|
|
12
|
+
"""
|
|
13
|
+
HTTP Response builder
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# Common status codes
|
|
17
|
+
STATUS_CODES = {
|
|
18
|
+
200: 'OK',
|
|
19
|
+
201: 'Created',
|
|
20
|
+
204: 'No Content',
|
|
21
|
+
301: 'Moved Permanently',
|
|
22
|
+
302: 'Found',
|
|
23
|
+
304: 'Not Modified',
|
|
24
|
+
400: 'Bad Request',
|
|
25
|
+
401: 'Unauthorized',
|
|
26
|
+
403: 'Forbidden',
|
|
27
|
+
404: 'Not Found',
|
|
28
|
+
405: 'Method Not Allowed',
|
|
29
|
+
429: 'Too Many Requests',
|
|
30
|
+
500: 'Internal Server Error',
|
|
31
|
+
501: 'Not Implemented',
|
|
32
|
+
503: 'Service Unavailable'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def __init__(self, body: Union[str, bytes, dict] = '',
|
|
36
|
+
status: int = 200,
|
|
37
|
+
headers: Optional[Dict[str, str]] = None,
|
|
38
|
+
content_type: str = 'text/html; charset=utf-8'):
|
|
39
|
+
"""
|
|
40
|
+
Initialize Response
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
body: Response body (str, bytes, or dict for JSON)
|
|
44
|
+
status: HTTP status code
|
|
45
|
+
headers: Response headers
|
|
46
|
+
content_type: Content-Type header
|
|
47
|
+
"""
|
|
48
|
+
self.status = status
|
|
49
|
+
self.headers = headers or {}
|
|
50
|
+
self.content_type = content_type
|
|
51
|
+
self._body = body
|
|
52
|
+
|
|
53
|
+
# Set default headers
|
|
54
|
+
if 'Content-Type' not in self.headers:
|
|
55
|
+
self.headers['Content-Type'] = content_type
|
|
56
|
+
|
|
57
|
+
self.headers['Server'] = 'Orbit/1.0'
|
|
58
|
+
self.headers['Date'] = self._http_date()
|
|
59
|
+
|
|
60
|
+
def _http_date(self) -> str:
|
|
61
|
+
"""Generate HTTP date string"""
|
|
62
|
+
return datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')
|
|
63
|
+
|
|
64
|
+
def set_header(self, name: str, value: str) -> 'Response':
|
|
65
|
+
"""
|
|
66
|
+
Set response header
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
name: Header name
|
|
70
|
+
value: Header value
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Self for chaining
|
|
74
|
+
"""
|
|
75
|
+
# Sanitize header value
|
|
76
|
+
value = str(value).replace('\r', '').replace('\n', '')
|
|
77
|
+
self.headers[name] = value
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def set_cookie(self, name: str, value: str, max_age: Optional[int] = None,
|
|
81
|
+
path: str = '/', domain: Optional[str] = None,
|
|
82
|
+
secure: bool = False, httponly: bool = True,
|
|
83
|
+
samesite: str = 'Lax') -> 'Response':
|
|
84
|
+
"""
|
|
85
|
+
Set cookie
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
name: Cookie name
|
|
89
|
+
value: Cookie value
|
|
90
|
+
max_age: Max age in seconds
|
|
91
|
+
path: Cookie path
|
|
92
|
+
domain: Cookie domain
|
|
93
|
+
secure: Secure flag
|
|
94
|
+
httponly: HttpOnly flag
|
|
95
|
+
samesite: SameSite attribute
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Self for chaining
|
|
99
|
+
"""
|
|
100
|
+
cookie_parts = [f"{name}={value}"]
|
|
101
|
+
|
|
102
|
+
if max_age is not None:
|
|
103
|
+
cookie_parts.append(f"Max-Age={max_age}")
|
|
104
|
+
|
|
105
|
+
cookie_parts.append(f"Path={path}")
|
|
106
|
+
|
|
107
|
+
if domain:
|
|
108
|
+
cookie_parts.append(f"Domain={domain}")
|
|
109
|
+
|
|
110
|
+
if secure:
|
|
111
|
+
cookie_parts.append("Secure")
|
|
112
|
+
|
|
113
|
+
if httponly:
|
|
114
|
+
cookie_parts.append("HttpOnly")
|
|
115
|
+
|
|
116
|
+
if samesite:
|
|
117
|
+
cookie_parts.append(f"SameSite={samesite}")
|
|
118
|
+
|
|
119
|
+
self.headers['Set-Cookie'] = '; '.join(cookie_parts)
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def get_body_bytes(self) -> bytes:
|
|
123
|
+
"""
|
|
124
|
+
Convert body to bytes
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Response body as bytes
|
|
128
|
+
"""
|
|
129
|
+
if isinstance(self._body, bytes):
|
|
130
|
+
return self._body
|
|
131
|
+
elif isinstance(self._body, dict):
|
|
132
|
+
return json.dumps(self._body).encode('utf-8')
|
|
133
|
+
elif isinstance(self._body, str):
|
|
134
|
+
return self._body.encode('utf-8')
|
|
135
|
+
else:
|
|
136
|
+
return str(self._body).encode('utf-8')
|
|
137
|
+
|
|
138
|
+
def to_http(self) -> bytes:
|
|
139
|
+
"""
|
|
140
|
+
Convert response to HTTP bytes
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Complete HTTP response as bytes
|
|
144
|
+
"""
|
|
145
|
+
# Status line
|
|
146
|
+
status_text = self.STATUS_CODES.get(self.status, 'Unknown')
|
|
147
|
+
status_line = f"HTTP/1.1 {self.status} {status_text}\r\n"
|
|
148
|
+
|
|
149
|
+
# Get body bytes
|
|
150
|
+
body_bytes = self.get_body_bytes()
|
|
151
|
+
|
|
152
|
+
# Set Content-Length if not already set
|
|
153
|
+
if 'Content-Length' not in self.headers:
|
|
154
|
+
self.headers['Content-Length'] = str(len(body_bytes))
|
|
155
|
+
|
|
156
|
+
# Build headers
|
|
157
|
+
header_lines = []
|
|
158
|
+
for name, value in self.headers.items():
|
|
159
|
+
header_lines.append(f"{name}: {value}\r\n")
|
|
160
|
+
|
|
161
|
+
# Combine all parts
|
|
162
|
+
response = status_line.encode('utf-8')
|
|
163
|
+
response += ''.join(header_lines).encode('utf-8')
|
|
164
|
+
response += b'\r\n'
|
|
165
|
+
response += body_bytes
|
|
166
|
+
|
|
167
|
+
return response
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def json(data: dict, status: int = 200, **kwargs) -> 'Response':
|
|
171
|
+
"""
|
|
172
|
+
Create JSON response
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
data: Data to serialize as JSON
|
|
176
|
+
status: HTTP status code
|
|
177
|
+
**kwargs: Additional response options
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Response object
|
|
181
|
+
"""
|
|
182
|
+
return Response(
|
|
183
|
+
body=data,
|
|
184
|
+
status=status,
|
|
185
|
+
content_type='application/json',
|
|
186
|
+
**kwargs
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def html(html: str, status: int = 200, **kwargs) -> 'Response':
|
|
191
|
+
"""
|
|
192
|
+
Create HTML response
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
html: HTML content
|
|
196
|
+
status: HTTP status code
|
|
197
|
+
**kwargs: Additional response options
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Response object
|
|
201
|
+
"""
|
|
202
|
+
return Response(
|
|
203
|
+
body=html,
|
|
204
|
+
status=status,
|
|
205
|
+
content_type='text/html; charset=utf-8',
|
|
206
|
+
**kwargs
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def text(text: str, status: int = 200, **kwargs) -> 'Response':
|
|
211
|
+
"""
|
|
212
|
+
Create plain text response
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
text: Text content
|
|
216
|
+
status: HTTP status code
|
|
217
|
+
**kwargs: Additional response options
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Response object
|
|
221
|
+
"""
|
|
222
|
+
return Response(
|
|
223
|
+
body=text,
|
|
224
|
+
status=status,
|
|
225
|
+
content_type='text/plain; charset=utf-8',
|
|
226
|
+
**kwargs
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def redirect(location: str, status: int = 302) -> 'Response':
|
|
231
|
+
"""
|
|
232
|
+
Create redirect response
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
location: Redirect URL
|
|
236
|
+
status: HTTP status code (301 or 302)
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Response object
|
|
240
|
+
"""
|
|
241
|
+
response = Response(body='', status=status)
|
|
242
|
+
response.set_header('Location', location)
|
|
243
|
+
return response
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def error(status: int = 500, message: str = None) -> 'Response':
|
|
247
|
+
"""
|
|
248
|
+
Create error response
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
status: HTTP status code
|
|
252
|
+
message: Error message
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Response object
|
|
256
|
+
"""
|
|
257
|
+
if message is None:
|
|
258
|
+
message = Response.STATUS_CODES.get(status, 'Error')
|
|
259
|
+
|
|
260
|
+
return Response.json({
|
|
261
|
+
'error': True,
|
|
262
|
+
'status': status,
|
|
263
|
+
'message': message
|
|
264
|
+
}, status=status)
|
orbit/core/router.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Router System
|
|
3
|
+
Path-tree based routing without decorators
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from typing import Dict, List, Callable, Optional, Tuple, Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RouteNode:
|
|
11
|
+
"""Node in the route tree"""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.handlers: Dict[str, Callable] = {} # method -> handler
|
|
15
|
+
self.children: Dict[str, RouteNode] = {} # static segments
|
|
16
|
+
self.param_child: Optional[Tuple[str, RouteNode]] = None # (param_name, node)
|
|
17
|
+
self.wildcard: bool = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Router:
|
|
21
|
+
"""
|
|
22
|
+
Path-tree based router for efficient route matching
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self.root = RouteNode()
|
|
27
|
+
self.routes: List[Dict[str, Any]] = [] # For debugging/listing
|
|
28
|
+
|
|
29
|
+
def add_route(self, path: str, methods: List[str], handler: Callable,
|
|
30
|
+
name: Optional[str] = None):
|
|
31
|
+
"""
|
|
32
|
+
Add route to router
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
path: URL path pattern (e.g., '/user/{id}/posts')
|
|
36
|
+
methods: List of HTTP methods
|
|
37
|
+
handler: Handler function
|
|
38
|
+
name: Optional route name
|
|
39
|
+
"""
|
|
40
|
+
# Normalize path
|
|
41
|
+
if not path.startswith('/'):
|
|
42
|
+
path = '/' + path
|
|
43
|
+
|
|
44
|
+
segments = [s for s in path.split('/') if s]
|
|
45
|
+
|
|
46
|
+
# Navigate/create tree
|
|
47
|
+
node = self.root
|
|
48
|
+
param_names = []
|
|
49
|
+
|
|
50
|
+
for segment in segments:
|
|
51
|
+
# Parameter segment
|
|
52
|
+
if segment.startswith('{') and segment.endswith('}'):
|
|
53
|
+
param_name = segment[1:-1]
|
|
54
|
+
param_names.append(param_name)
|
|
55
|
+
|
|
56
|
+
if node.param_child is None:
|
|
57
|
+
node.param_child = (param_name, RouteNode())
|
|
58
|
+
node = node.param_child[1]
|
|
59
|
+
|
|
60
|
+
# Static segment
|
|
61
|
+
else:
|
|
62
|
+
if segment not in node.children:
|
|
63
|
+
node.children[segment] = RouteNode()
|
|
64
|
+
node = node.children[segment]
|
|
65
|
+
|
|
66
|
+
# Register handlers for each method
|
|
67
|
+
for method in methods:
|
|
68
|
+
method = method.upper()
|
|
69
|
+
if method in node.handlers:
|
|
70
|
+
raise ValueError(f"Route already registered: {method} {path}")
|
|
71
|
+
node.handlers[method] = handler
|
|
72
|
+
|
|
73
|
+
# Store route info
|
|
74
|
+
self.routes.append({
|
|
75
|
+
'path': path,
|
|
76
|
+
'methods': methods,
|
|
77
|
+
'handler': handler,
|
|
78
|
+
'name': name,
|
|
79
|
+
'params': param_names
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
def match(self, path: str, method: str) -> Optional[Tuple[Callable, Dict[str, str]]]:
|
|
83
|
+
"""
|
|
84
|
+
Match path to handler
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
path: Request path
|
|
88
|
+
method: HTTP method
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Tuple of (handler, path_params) or None if no match
|
|
92
|
+
"""
|
|
93
|
+
if not path.startswith('/'):
|
|
94
|
+
path = '/' + path
|
|
95
|
+
|
|
96
|
+
segments = [s for s in path.split('/') if s]
|
|
97
|
+
|
|
98
|
+
return self._match_recursive(self.root, segments, method, {})
|
|
99
|
+
|
|
100
|
+
def _match_recursive(self, node: RouteNode, segments: List[str],
|
|
101
|
+
method: str, params: Dict[str, str]) -> Optional[Tuple[Callable, Dict[str, str]]]:
|
|
102
|
+
"""
|
|
103
|
+
Recursively match route segments
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
node: Current route node
|
|
107
|
+
segments: Remaining path segments
|
|
108
|
+
method: HTTP method
|
|
109
|
+
params: Accumulated path parameters
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Tuple of (handler, params) or None
|
|
113
|
+
"""
|
|
114
|
+
# No more segments - check for handler
|
|
115
|
+
if not segments:
|
|
116
|
+
handler = node.handlers.get(method.upper())
|
|
117
|
+
if handler:
|
|
118
|
+
return handler, params
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
segment = segments[0]
|
|
122
|
+
remaining = segments[1:]
|
|
123
|
+
|
|
124
|
+
# Try static match first
|
|
125
|
+
if segment in node.children:
|
|
126
|
+
result = self._match_recursive(node.children[segment], remaining, method, params)
|
|
127
|
+
if result:
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
# Try parameter match
|
|
131
|
+
if node.param_child:
|
|
132
|
+
param_name, param_node = node.param_child
|
|
133
|
+
new_params = params.copy()
|
|
134
|
+
new_params[param_name] = segment
|
|
135
|
+
result = self._match_recursive(param_node, remaining, method, new_params)
|
|
136
|
+
if result:
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def route(self, path: str, methods: List[str] = None, name: str = None):
|
|
142
|
+
"""
|
|
143
|
+
Decorator for adding routes (optional alternative to add_route)
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
path: URL path pattern
|
|
147
|
+
methods: List of HTTP methods
|
|
148
|
+
name: Optional route name
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Decorator function
|
|
152
|
+
"""
|
|
153
|
+
if methods is None:
|
|
154
|
+
methods = ['GET']
|
|
155
|
+
|
|
156
|
+
def decorator(handler: Callable) -> Callable:
|
|
157
|
+
self.add_route(path, methods, handler, name)
|
|
158
|
+
return handler
|
|
159
|
+
return decorator
|
|
160
|
+
|
|
161
|
+
def get_routes(self) -> List[Dict[str, Any]]:
|
|
162
|
+
"""Get list of all registered routes"""
|
|
163
|
+
return self.routes.copy()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class RouterGroup:
|
|
167
|
+
"""
|
|
168
|
+
Route group for organizing routes with common prefix/middleware
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(self, router: Router, prefix: str = ''):
|
|
172
|
+
self.router = router
|
|
173
|
+
self.prefix = prefix.rstrip('/')
|
|
174
|
+
|
|
175
|
+
def add_route(self, path: str, methods: List[str], handler: Callable,
|
|
176
|
+
name: Optional[str] = None):
|
|
177
|
+
"""Add route with group prefix"""
|
|
178
|
+
full_path = self.prefix + path
|
|
179
|
+
self.router.add_route(full_path, methods, handler, name)
|
|
180
|
+
|
|
181
|
+
def group(self, prefix: str) -> 'RouterGroup':
|
|
182
|
+
"""Create sub-group"""
|
|
183
|
+
return RouterGroup(self.router, self.prefix + prefix)
|
orbit/http/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# HTTP module
|
orbit/http/parser.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP Request Parser
|
|
3
|
+
Manual HTTP/1.1 request parsing without external libraries
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from urllib.parse import parse_qs, unquote
|
|
8
|
+
from typing import Dict, Tuple, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HTTPParser:
|
|
12
|
+
"""Parse raw HTTP requests into structured data"""
|
|
13
|
+
|
|
14
|
+
# HTTP Method validation
|
|
15
|
+
VALID_METHODS = {'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'}
|
|
16
|
+
|
|
17
|
+
# Maximum sizes for security
|
|
18
|
+
MAX_REQUEST_LINE = 8192 # 8KB
|
|
19
|
+
MAX_HEADERS = 100
|
|
20
|
+
MAX_HEADER_SIZE = 8192 # 8KB per header
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def parse_request_line(line: str) -> Tuple[str, str, str]:
|
|
24
|
+
"""
|
|
25
|
+
Parse HTTP request line
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
line: First line of HTTP request
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Tuple of (method, path, version)
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ValueError: If request line is malformed
|
|
35
|
+
"""
|
|
36
|
+
if len(line) > HTTPParser.MAX_REQUEST_LINE:
|
|
37
|
+
raise ValueError("Request line too long")
|
|
38
|
+
|
|
39
|
+
parts = line.strip().split(' ')
|
|
40
|
+
if len(parts) != 3:
|
|
41
|
+
raise ValueError("Malformed request line")
|
|
42
|
+
|
|
43
|
+
method, full_path, version = parts
|
|
44
|
+
|
|
45
|
+
# Validate HTTP method
|
|
46
|
+
if method.upper() not in HTTPParser.VALID_METHODS:
|
|
47
|
+
raise ValueError(f"Invalid HTTP method: {method}")
|
|
48
|
+
|
|
49
|
+
# Validate HTTP version
|
|
50
|
+
if not version.startswith('HTTP/'):
|
|
51
|
+
raise ValueError(f"Invalid HTTP version: {version}")
|
|
52
|
+
|
|
53
|
+
return method.upper(), full_path, version
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def parse_path(full_path: str) -> Tuple[str, Dict[str, list]]:
|
|
57
|
+
"""
|
|
58
|
+
Parse path and query string
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
full_path: Full path including query string
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Tuple of (path, query_params)
|
|
65
|
+
"""
|
|
66
|
+
if '?' in full_path:
|
|
67
|
+
path, query_string = full_path.split('?', 1)
|
|
68
|
+
query_params = parse_qs(query_string, keep_blank_values=True)
|
|
69
|
+
else:
|
|
70
|
+
path, query_params = full_path, {}
|
|
71
|
+
|
|
72
|
+
# Decode and normalize path
|
|
73
|
+
path = unquote(path)
|
|
74
|
+
|
|
75
|
+
# Path traversal protection
|
|
76
|
+
if '..' in path or path.startswith('//'):
|
|
77
|
+
raise ValueError("Path traversal attempt detected")
|
|
78
|
+
|
|
79
|
+
return path, query_params
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def parse_headers(header_lines: list) -> Dict[str, str]:
|
|
83
|
+
"""
|
|
84
|
+
Parse HTTP headers
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
header_lines: List of header lines
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Dictionary of headers (lowercase keys)
|
|
91
|
+
"""
|
|
92
|
+
headers = {}
|
|
93
|
+
|
|
94
|
+
if len(header_lines) > HTTPParser.MAX_HEADERS:
|
|
95
|
+
raise ValueError("Too many headers")
|
|
96
|
+
|
|
97
|
+
for line in header_lines:
|
|
98
|
+
if not line.strip():
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
if len(line) > HTTPParser.MAX_HEADER_SIZE:
|
|
102
|
+
raise ValueError("Header too large")
|
|
103
|
+
|
|
104
|
+
if ':' not in line:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
key, value = line.split(':', 1)
|
|
108
|
+
key = key.strip().lower()
|
|
109
|
+
value = value.strip()
|
|
110
|
+
|
|
111
|
+
# Prevent header injection
|
|
112
|
+
if '\r' in value or '\n' in value:
|
|
113
|
+
raise ValueError("Header injection attempt detected")
|
|
114
|
+
|
|
115
|
+
headers[key] = value
|
|
116
|
+
|
|
117
|
+
return headers
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def parse_body(body_data: bytes, content_type: Optional[str],
|
|
121
|
+
content_length: int) -> Tuple[bytes, Optional[dict]]:
|
|
122
|
+
"""
|
|
123
|
+
Parse request body based on content type
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
body_data: Raw body bytes
|
|
127
|
+
content_type: Content-Type header value
|
|
128
|
+
content_length: Content-Length header value
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Tuple of (raw_body, parsed_data)
|
|
132
|
+
"""
|
|
133
|
+
# Size validation
|
|
134
|
+
if content_length > 10 * 1024 * 1024: # 10MB limit
|
|
135
|
+
raise ValueError("Request body too large")
|
|
136
|
+
|
|
137
|
+
if not content_type:
|
|
138
|
+
return body_data, None
|
|
139
|
+
|
|
140
|
+
parsed_data = None
|
|
141
|
+
|
|
142
|
+
# Parse JSON
|
|
143
|
+
if 'application/json' in content_type:
|
|
144
|
+
try:
|
|
145
|
+
import json
|
|
146
|
+
parsed_data = json.loads(body_data.decode('utf-8'))
|
|
147
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
148
|
+
raise ValueError("Invalid JSON body")
|
|
149
|
+
|
|
150
|
+
# Parse form data
|
|
151
|
+
elif 'application/x-www-form-urlencoded' in content_type:
|
|
152
|
+
try:
|
|
153
|
+
form_str = body_data.decode('utf-8')
|
|
154
|
+
parsed_data = parse_qs(form_str, keep_blank_values=True)
|
|
155
|
+
# Convert single-item lists to values
|
|
156
|
+
parsed_data = {k: v[0] if len(v) == 1 else v
|
|
157
|
+
for k, v in parsed_data.items()}
|
|
158
|
+
except UnicodeDecodeError:
|
|
159
|
+
raise ValueError("Invalid form data")
|
|
160
|
+
|
|
161
|
+
return body_data, parsed_data
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def sanitize_header_value(value: str) -> str:
|
|
165
|
+
"""
|
|
166
|
+
Sanitize header value to prevent injection attacks
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
value: Header value to sanitize
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Sanitized header value
|
|
173
|
+
"""
|
|
174
|
+
# Remove any newline characters
|
|
175
|
+
value = value.replace('\r', '').replace('\n', '')
|
|
176
|
+
return value.strip()
|