Compare commits

..

14 Commits

Author SHA1 Message Date
0880
fe65fafbe0 Errors and other stuff 2026-01-21 01:55:33 +03:30
0880
f91943cde2 Fix valid method condition 2026-01-20 22:19:57 +03:30
0880
e679865e50 Fix typing error 2026-01-20 22:19:09 +03:30
0880
a7f0001659 Move responses to another module 2026-01-20 21:06:59 +03:30
0880
66f5eb102b Remove app from request use new response format 2026-01-20 21:03:21 +03:30
0880
4e8a5851d5 ... 2026-01-20 21:01:32 +03:30
0880
db8d5c4454 Use set for CORS variables 2026-01-20 21:01:24 +03:30
0880
e67ef7d790 Add json() method for parsing request 2026-01-20 20:20:20 +03:30
0880
24334d1def Fix code 2026-01-20 19:17:51 +03:30
0880
936e209fb8 Merge branch 'main' of http://chai.bokhary.ir/0880/SlowAPI 2026-01-20 19:16:00 +03:30
0880
5eaa5527b6 Add redirect 2026-01-20 19:15:57 +03:30
0880
3164c446c6 Merge branch 'main' of http://chai.bokhary.ir/0880/SlowAPI 2026-01-17 19:32:27 +03:30
0880
57ac6bcdd3 slow 2026-01-17 19:32:25 +03:30
0880
bd4a4bd6ac Major bug for JSONAPI 2026-01-17 19:29:58 +03:30
4 changed files with 215 additions and 85 deletions

View File

@@ -1,11 +1,19 @@
from .slow import JSONAPI, App, Headers, HTTPResponse, JSONResponse, Request, render from . import responses
from .slow import (
JSONAPI,
App,
Headers,
Request,
redirect,
render,
)
__all__ = [ __all__ = [
"JSONAPI", "JSONAPI",
"App", "App",
"HTTPResponse",
"JSONResponse",
"render", "render",
"Request", "Request",
"Headers", "Headers",
"redirect",
"responses",
] ]

38
slow/_errors.py Normal file
View File

@@ -0,0 +1,38 @@
import inspect
def error_not_async(func):
if not inspect.iscoroutinefunction(func):
lines, start = inspect.getsourcelines(func)
fdef_index = 0
while fdef_index < len(lines) and not lines[fdef_index].strip().startswith(
"def"
):
fdef_index += 1
if fdef_index < len(lines):
line_num = start + fdef_index
fdef_content = lines[fdef_index].strip()
ERROR_HEADER = "\033[1m\033[91m[FATAL_EXECUTION_ERROR]: Non-Asynchronous Route Detected\033[0m"
ERROR_BODY = (
f"\n"
f'--> File "{inspect.getsourcefile(func)}", line {line_num}, in {func.__name__}\n\n'
f"\033[1m[CONSTRAINT_VIOLATION]\033[0m\n"
f" Synchronous function used where an async coroutine was required.\n"
f" \033[93mCode Traceback:\033[0m\n"
f" {line_num}: \033[93m{fdef_content}\033[0m\n\n"
f"\033[1m[SUGGESTED_PATCH]\033[0m\n"
f" Apply the 'async' keyword to the function signature:\n"
f" {line_num}: \033[92masync {fdef_content}\033[0m"
)
raise RuntimeError(ERROR_HEADER + ERROR_BODY)
else:
raise RuntimeError(
"\033[1m\033[91m[FATAL_EXECUTION_ERROR]: Non-Asynchronous Route Detected\033[0m"
)

54
slow/responses.py Normal file
View File

@@ -0,0 +1,54 @@
import http.client
import json
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .slow import App
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
def HTTPResponse(
content: str | bytes,
status=200,
content_type="text/plain; charset=utf-8",
headers=[],
) -> Response:
if isinstance(content, str):
content_bytes = content.encode(encoding="utf-8")
else:
content_bytes = content
return Response(
status, [f"Content-Type: {content_type}", *headers], body=content_bytes
)
def JSONResponse(d: dict, status=200) -> Response:
return HTTPResponse(
json.dumps(d), status=status, content_type="text/json; charset=utf-8"
)

View File

@@ -1,23 +1,26 @@
import asyncio import asyncio
import http.client
import json import json
import re import re
import urllib.parse import urllib.parse
from json.decoder import JSONDecodeError
from pathlib import Path from pathlib import Path
from typing import Any, Awaitable, Callable, Optional from typing import Any, Awaitable, Callable, Optional
from . import _errors
from .responses import HTTPResponse, JSONResponse, Response
PR = re.compile(r"\<([a-zA-Z_][a-zA-Z0-9_]*)\>") PR = re.compile(r"\<([a-zA-Z_][a-zA-Z0-9_]*)\>")
class CORS: class CORS:
Origins: list[str] Origins: set[str]
Methods: list[str] Methods: set[str]
Disabled: bool Disabled: bool
def __init__(self): def __init__(self):
self.Disabled = False self.Disabled = False
self.Origins = [] self.Origins: set[str] = {}
self.Methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"] self.Methods: set[str] = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"}
class Headers: class Headers:
@@ -38,14 +41,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(
@@ -57,25 +57,33 @@ class Request:
} }
) )
def json(self) -> dict | None:
try:
return json.loads(self.body)
except JSONDecodeError:
return None
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 HTTPResponse("404 Not Found", status=404)
encoding="utf-8"
)
async def _default_405_route(request: Request): async def _default_405_route(request: Request):
return "HTTP/1.1 405 Method Not Allowed\r\nContent-Type: text/html\r\n\r\n405 Method Not Allowed".encode( return HTTPResponse(b"", status=405)
encoding="utf-8"
)
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 | None]],
],
] = {} ] = {}
self.error_routes: dict[int, Callable[[Request, ...], Awaitable[bytes]]] = { self.error_routes: dict[
int, Callable[[Request, ...], Awaitable[Response | None]]
] = {
404: _default_404_route, 404: _default_404_route,
405: _default_405_route, 405: _default_405_route,
} }
@@ -90,10 +98,14 @@ 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 | None]],
): ):
if method not in ["GET", "POST", "PUT", "DELETE"]: if method not in ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"]:
raise RuntimeError(f'Invalid method "{method}".') raise RuntimeError(f'Invalid method "{method}".')
_errors.error_not_async(func)
pat = self._pattern_to_regex(path) pat = self._pattern_to_regex(path)
if pat not in self.routes: if pat not in self.routes:
self.routes[pat] = {} self.routes[pat] = {}
@@ -104,8 +116,16 @@ 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 | None]]):
self._serve(path, "GET", func) self._serve(path, "GET", func)
async def wrapper(*args, **kwargs):
res = await func(*args, **kwargs)
if isinstance(res, Response):
res.no_header = True
return res
self._serve(path, "HEAD", wrapper)
return func return func
return decorator return decorator
@@ -113,7 +133,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 | None]]):
self._serve(path, "POST", func) self._serve(path, "POST", func)
return func return func
@@ -122,14 +142,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 | None]]):
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 | None]]):
self._serve(path, "DELETE", func) self._serve(path, "DELETE", func)
return func return func
@@ -138,7 +158,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 | None]]):
self._serve(path, "PATCH", func) self._serve(path, "PATCH", func)
return func return func
@@ -147,7 +167,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 | None]]):
self._serve(path, "HEAD", func) self._serve(path, "HEAD", func)
return func return func
@@ -162,16 +182,22 @@ 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 or method == "websocket":
return self.error_routes[405], {} return self.error_routes[405], {}
return route[method], { return route[method], {
k: urllib.parse.unquote(v) for k, v in m.groupdict().items() k: urllib.parse.unquote(v) for k, v in m.groupdict().items()
} } # ty:ignore[invalid-return-type]
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() - {"websocket"})
return set()
async def handle_client( async def handle_client(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
): ):
@@ -182,6 +208,12 @@ class App:
if not request_line: if not request_line:
return return
if not request_line.decode(encoding="utf-8").startswith(
("GET", "POST", "PUT", "HEAD", "DELETE", "PATCH", "OPTIONS")
):
# Probably WebSocket
pass
# Parse request line # Parse request line
parts = request_line.decode(encoding="utf-8").strip().split() parts = request_line.decode(encoding="utf-8").strip().split()
if len(parts) < 3: if len(parts) < 3:
@@ -204,45 +236,70 @@ 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( if (
method == "GET"
and "connection" in headers
and headers.get("connection") == "Upgrade"
and "upgrade" in headers
):
# Upgrade
return Response(
status=426, headers=["Connection: close"], body=b""
).render(self)
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:
raise e
print(f"Internal Server Error: {e}") print(f"Internal Server Error: {e}")
finally: finally:
writer.close() writer.close()
@@ -258,53 +315,26 @@ class App:
await server.serve_forever() await server.serve_forever()
def HTTPResponse(
request: Request,
content: str | bytes,
status=200,
content_type="text/plain; charset=utf-8",
headers=[],
) -> bytes:
content_bytes = content
if isinstance(content, str):
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"
if (
"origin" in request.headers
and not request.app.CORS.Disabled
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: # TODO Move to another module
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 JSONResponse(request: Request, d: dict, status=200) -> bytes: def redirect(location: str): # TODO Move to another module
return HTTPResponse( return Response(307, ["Location: {location}"], b"")
request, json.dumps(d), status=status, content_type="text/json; charset=utf-8"
)
def JSONAPI(func): def JSONAPI(func): # TODO Move to another module
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
result = await func(*args, **kwargs) result = await func(*args, **kwargs)
if not isinstance(result, dict): if not isinstance(result, dict):
@@ -316,6 +346,6 @@ def JSONAPI(func):
): ):
return JSONResponse(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