Remove app from request use new response format

This commit is contained in:
0880
2026-01-20 21:03:21 +03:30
parent 4e8a5851d5
commit 66f5eb102b

View File

@@ -39,14 +39,11 @@ class Headers:
class Request: class Request:
def __init__( def __init__(self, method: str, path: str, headers: Headers, body: bytes):
self, method: str, path: str, headers: Headers, body: bytes, app: "App"
):
self.method = method self.method = method
self.path = path self.path = path
self.headers = headers self.headers = headers
self.body = body self.body = body
self.app = app
def __str__(self): def __str__(self):
return str( return str(
@@ -64,6 +61,34 @@ class Request:
except JSONDecodeError: except JSONDecodeError:
return None return None
class Response:
status: int
headers: list[str]
body: bytes
def __init__(self, status: int, headers: list[str], body: bytes):
self.status = status
self.headers = headers
self.body = body
self.no_header = False
def header(self, app: "App") -> bytes:
header = f"HTTP/1.1 {self.status} {http.client.responses.get(self.status, 'Unkown Status Code')}\r\n"
header += f"Content-Length: {len(self.body)}\r\n"
for h in self.headers:
header += h + "\r\n"
header += "\r\n"
return header.encode(encoding="utf-8")
def render(self, app: "App") -> bytes:
if self.no_header:
return self.header(app)
return self.header(app) + self.body
async def _default_404_route(request: Request): async def _default_404_route(request: Request):
return "HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\n\r\n404 Not Found contact admin".encode( return "HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\n\r\n404 Not Found contact admin".encode(
encoding="utf-8" encoding="utf-8"
@@ -79,9 +104,9 @@ async def _default_405_route(request: Request):
class App: class App:
def __init__(self): def __init__(self):
self.routes: dict[ self.routes: dict[
re.Pattern[str], dict[str, Callable[[Request, ...], Awaitable[bytes]]] re.Pattern[str], dict[str, Callable[[Request, ...], Awaitable[Response]]]
] = {} ] = {}
self.error_routes: dict[int, Callable[[Request, ...], Awaitable[bytes]]] = { self.error_routes: dict[int, Callable[[Request, ...], Awaitable[Response]]] = {
404: _default_404_route, 404: _default_404_route,
405: _default_405_route, 405: _default_405_route,
} }
@@ -96,7 +121,10 @@ class App:
return re.compile(re_temp) return re.compile(re_temp)
def _serve( def _serve(
self, path: str, method: str, func: Callable[[Request, ...], Awaitable[bytes]] self,
path: str,
method: str,
func: Callable[[Request, ...], Awaitable[Response]],
): ):
if method not in ["GET", "POST", "PUT", "DELETE"]: if method not in ["GET", "POST", "PUT", "DELETE"]:
raise RuntimeError(f'Invalid method "{method}".') raise RuntimeError(f'Invalid method "{method}".')
@@ -110,8 +138,15 @@ class App:
def GET(self, path: str): def GET(self, path: str):
"""Decorator to register a GET HTTP route.""" """Decorator to register a GET HTTP route."""
def decorator(func: Callable[[Request, ...], Awaitable[bytes]]): def decorator(func: Callable[[Request, ...], Awaitable[Response]]):
self._serve(path, "GET", func) self._serve(path, "GET", func)
async def wrapper(*args, **kwargs):
res: Response = await func(*args, **kwargs)
res.no_header = True
return res
self._serve(path, "HEAD", wrapper)
return func return func
return decorator return decorator
@@ -119,7 +154,7 @@ class App:
def POST(self, path: str): def POST(self, path: str):
"""Decorator to register a POST HTTP route.""" """Decorator to register a POST HTTP route."""
def decorator(func: Callable[[Request, ...], Awaitable[bytes]]): def decorator(func: Callable[[Request, ...], Awaitable[Response]]):
self._serve(path, "POST", func) self._serve(path, "POST", func)
return func return func
@@ -128,14 +163,14 @@ class App:
def PUT(self, path: str): def PUT(self, path: str):
"""Decorator to register a PUT HTTP route.""" """Decorator to register a PUT HTTP route."""
def decorator(func: Callable[[Request, ...], Awaitable[bytes]]): def decorator(func: Callable[[Request, ...], Awaitable[Response]]):
self._serve(path, "PUT", func) self._serve(path, "PUT", func)
return func return func
def DELETE(self, path: str): def DELETE(self, path: str):
"""Decorator to register a DELETE HTTP route.""" """Decorator to register a DELETE HTTP route."""
def decorator(func: Callable[[Request, ...], Awaitable[bytes]]): def decorator(func: Callable[[Request, ...], Awaitable[Response]]):
self._serve(path, "DELETE", func) self._serve(path, "DELETE", func)
return func return func
@@ -144,7 +179,7 @@ class App:
def PATCH(self, path: str): def PATCH(self, path: str):
"""Decorator to register a PATCH HTTP route.""" """Decorator to register a PATCH HTTP route."""
def decorator(func: Callable[[Request, ...], Awaitable[bytes]]): def decorator(func: Callable[[Request, ...], Awaitable[Response]]):
self._serve(path, "PATCH", func) self._serve(path, "PATCH", func)
return func return func
@@ -153,7 +188,7 @@ class App:
def HEAD(self, path: str): def HEAD(self, path: str):
"""Decorator to register a HEAD HTTP route.""" """Decorator to register a HEAD HTTP route."""
def decorator(func: Callable[[Request, ...], Awaitable[bytes]]): def decorator(func: Callable[[Request, ...], Awaitable[Response]]):
self._serve(path, "HEAD", func) self._serve(path, "HEAD", func)
return func return func
@@ -168,7 +203,7 @@ class App:
return decorator return decorator
def resolve(self, path, method) -> tuple[Callable[..., Awaitable[bytes]], dict]: def resolve(self, path, method) -> tuple[Callable[..., Awaitable[Response]], dict]:
for pattern, route in self.routes.items(): for pattern, route in self.routes.items():
if m := pattern.fullmatch(path): if m := pattern.fullmatch(path):
if method not in route: if method not in route:
@@ -178,6 +213,12 @@ class App:
} }
return self.error_routes[404], {} return self.error_routes[404], {}
def methods(self, path) -> set[str]:
for pattern, route in self.routes.items():
if pattern.fullmatch(path):
return set(route.keys())
return set()
async def handle_client( async def handle_client(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
): ):
@@ -210,42 +251,55 @@ class App:
content_length = int(headers.get("Content-Length", 0)) content_length = int(headers.get("Content-Length", 0))
body = await reader.read(content_length) if content_length else b"" body = await reader.read(content_length) if content_length else b""
if method == "OPTIONS": if (
method == "OPTIONS"
): # FIXME Should handle responding with available methods as well
if "origin" in headers and headers.get("origin") in self.CORS.Origins: if "origin" in headers and headers.get("origin") in self.CORS.Origins:
origin = headers.get("origin") origin = headers.get("origin")
response = "HTTP/1.1 200 OK\r\n" head = [
response += "Content-Type: text/plain\r\n" "Content-Type: text/plain",
response += "Content-Length: 0\r\n" "Content-Length: 0",
response += f"Access-Control-Allow-Origin: {origin}\r\n" f"Access-Control-Allow-Origin: {origin}",
response += f"Access-Control-Allow-Methods: {','.join(self.CORS.Methods)}\r\n" f"Access-Control-Allow-Methods: {','.join(self.CORS.Methods)}",
response += "Access-Control-Allow-Headers: Content-Type,Authorization\r\n" # CORS "Access-Control-Allow-Headers: Content-Type,Authorization", # CORS
response += "Vary: Origin\r\n" "Vary: Origin",
response += "\r\n" ]
writer.write(response.encode(encoding="utf-8")) writer.write(Response(200, head, b"").render(self))
await writer.drain() await writer.drain()
else: else:
response = "HTTP/1.1 403 Forbidden\r\n" writer.write(Response(403, ["Vary: Origin"], b"").render(self))
response += "Content-Length: 0\r\n"
response += "Vary: Origin\r\n"
response += "\r\n"
writer.write(response.encode(encoding="utf-8"))
await writer.drain() await writer.drain()
else: else:
route, kwargs = self.resolve(path, method) route, kwargs = self.resolve(path, method)
response = await route( response: Response = await route(
request=Request( request=Request(
method=method, path=path, headers=headers, body=body, app=self method=method, path=path, headers=headers, body=body
), ),
**kwargs, **kwargs,
) )
writer.write(response) response.headers.append("Vary: Origin")
if (
"origin" in headers
and self.CORS.Disabled
and headers.get("origin") in self.CORS.Origins
): # CORS
response.headers.append(
f"Access-Control-Allow-Origin: {headers.get('origin')}"
)
response.headers.append(
f"Access-Control-Allow-Methods: {','.join(self.CORS.Methods & self.methods(path))}"
)
response.headers.append(
"Access-Control-Allow-Headers: Content-Type,Authorization\r\n"
)
writer.write(response.render(self))
await writer.drain() await writer.drain()
except Exception as e: except Exception as e:
@@ -266,54 +320,40 @@ class App:
def HTTPResponse( def HTTPResponse(
request: Request,
content: str | bytes, content: str | bytes,
status=200, status=200,
content_type="text/plain; charset=utf-8", content_type="text/plain; charset=utf-8",
headers=[], headers=[],
) -> bytes: ) -> Response:
content_bytes = content
if isinstance(content, str): if isinstance(content, str):
content_bytes = content.encode(encoding="utf-8") content_bytes = content.encode(encoding="utf-8")
head: str = f"HTTP/1.1 {status} {http.client.responses.get(status, 'Unkown Status Code')}\r\nContent-Type: {content_type}\r\nContent-Length: {len(content_bytes)}\r\n" else:
if ( content_bytes = content
"origin" in request.headers return Response(
and not request.app.CORS.Disabled status, [f"Content-Type: {content_type}", *headers], body=content_bytes
and request.headers.get("origin") in request.app.CORS.Origins )
):
head += (
f"Access-Control-Allow-Origin: {request.headers.get('origin')}\r\n" # CORS
)
head += f"Access-Control-Allow-Methods: {','.join(request.app.CORS.Methods)}\r\n" # CORS
head += "Access-Control-Allow-Headers: Content-Type,Authorization\r\n" # CORS
head += "Vary: Origin\r\n"
head += "\r\n".join(headers) + ("\r\n" if len(headers) > 0 else "")
head += "\r\n"
return head.encode(encoding="utf-8") + content_bytes
_value_pattern = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z_0-9]*)\s*\}\}") _value_pattern = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z_0-9]*)\s*\}\}")
def render(request: Request, file: str | Path, variables: dict[str, Any] = {}) -> bytes: def render(file: str | Path, variables: dict[str, Any] = {}) -> Response:
if isinstance(file, str): if isinstance(file, str):
file = Path(file) file = Path(file)
content: str = file.read_text(encoding="utf-8") content: str = file.read_text(encoding="utf-8")
for m in _value_pattern.findall(content): for m in _value_pattern.findall(content):
if m in variables: if m in variables:
content = re.sub(r"\{\{\s*" + m + r"\s*\}\}", variables[m], content) content = re.sub(r"\{\{\s*" + m + r"\s*\}\}", variables[m], content)
return HTTPResponse(request, content, content_type="text/html; charset=utf-8") return HTTPResponse(content, content_type="text/html; charset=utf-8")
def redirect(location: str): def redirect(location: str):
return f"HTTP/1.1 307 Temporary Redirect\r\nContent-Length: 0\r\nLocation: {location}".encode( return Response(307, ["Location: {location}"], b"")
encoding="utf-8"
)
def JSONResponse(request: Request, d: dict, status=200) -> bytes: def JSONResponse(d: dict, status=200) -> Response:
return HTTPResponse( return HTTPResponse(
request, json.dumps(d), status=status, content_type="text/json; charset=utf-8" json.dumps(d), status=status, content_type="text/json; charset=utf-8"
) )
@@ -327,8 +367,8 @@ def JSONAPI(func):
and isinstance(result[1], dict) and isinstance(result[1], dict)
and isinstance(result[0], int) and isinstance(result[0], int)
): ):
return JSONResponse(kwargs["request"], result[1], result[0]) return JSONResponse(result[1], result[0])
raise RuntimeError("Return value of JSONAPI route is not a dictionary") raise RuntimeError("Return value of JSONAPI route is not a dictionary")
return JSONResponse(kwargs["request"], result) return JSONResponse(result)
return wrapper return wrapper