Compare commits

...

16 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
0880
dd648bc50f Fix JSONAPI 2026-01-17 18:53:54 +03:30
0880
4e04784f9b Allow bytes content HTTPRepsonse 2026-01-17 18:31:27 +03:30
4 changed files with 215 additions and 83 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__ = [
"JSONAPI",
"App",
"HTTPResponse",
"JSONResponse",
"render",
"Request",
"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 http.client
import json
import re
import urllib.parse
from json.decoder import JSONDecodeError
from pathlib import Path
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_]*)\>")
class CORS:
Origins: list[str]
Methods: list[str]
Origins: set[str]
Methods: set[str]
Disabled: bool
def __init__(self):
self.Disabled = False
self.Origins = []
self.Methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"]
self.Origins: set[str] = {}
self.Methods: set[str] = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"}
class Headers:
@@ -38,14 +41,11 @@ class Headers:
class Request:
def __init__(
self, method: str, path: str, headers: Headers, body: bytes, app: "App"
):
def __init__(self, method: str, path: str, headers: Headers, body: bytes):
self.method = method
self.path = path
self.headers = headers
self.body = body
self.app = app
def __str__(self):
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):
return "HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\n\r\n404 Not Found contact admin".encode(
encoding="utf-8"
)
return HTTPResponse("404 Not Found", status=404)
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(
encoding="utf-8"
)
return HTTPResponse(b"", status=405)
class App:
def __init__(self):
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,
405: _default_405_route,
}
@@ -90,10 +98,14 @@ class App:
return re.compile(re_temp)
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}".')
_errors.error_not_async(func)
pat = self._pattern_to_regex(path)
if pat not in self.routes:
self.routes[pat] = {}
@@ -104,8 +116,16 @@ class App:
def GET(self, path: str):
"""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)
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 decorator
@@ -113,7 +133,7 @@ class App:
def POST(self, path: str):
"""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)
return func
@@ -122,14 +142,14 @@ class App:
def PUT(self, path: str):
"""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)
return func
def DELETE(self, path: str):
"""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)
return func
@@ -138,7 +158,7 @@ class App:
def PATCH(self, path: str):
"""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)
return func
@@ -147,7 +167,7 @@ class App:
def HEAD(self, path: str):
"""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)
return func
@@ -162,16 +182,22 @@ class App:
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():
if m := pattern.fullmatch(path):
if method not in route:
if method not in route or method == "websocket":
return self.error_routes[405], {}
return route[method], {
k: urllib.parse.unquote(v) for k, v in m.groupdict().items()
}
} # ty:ignore[invalid-return-type]
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(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
):
@@ -182,6 +208,12 @@ class App:
if not request_line:
return
if not request_line.decode(encoding="utf-8").startswith(
("GET", "POST", "PUT", "HEAD", "DELETE", "PATCH", "OPTIONS")
):
# Probably WebSocket
pass
# Parse request line
parts = request_line.decode(encoding="utf-8").strip().split()
if len(parts) < 3:
@@ -204,45 +236,70 @@ class App:
content_length = int(headers.get("Content-Length", 0))
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:
origin = headers.get("origin")
response = "HTTP/1.1 200 OK\r\n"
response += "Content-Type: text/plain\r\n"
response += "Content-Length: 0\r\n"
response += f"Access-Control-Allow-Origin: {origin}\r\n"
response += f"Access-Control-Allow-Methods: {','.join(self.CORS.Methods)}\r\n"
response += "Access-Control-Allow-Headers: Content-Type,Authorization\r\n" # CORS
response += "Vary: Origin\r\n"
response += "\r\n"
head = [
"Content-Type: text/plain",
"Content-Length: 0",
f"Access-Control-Allow-Origin: {origin}",
f"Access-Control-Allow-Methods: {','.join(self.CORS.Methods)}",
"Access-Control-Allow-Headers: Content-Type,Authorization", # CORS
"Vary: Origin",
]
writer.write(response.encode(encoding="utf-8"))
writer.write(Response(200, head, b"").render(self))
await writer.drain()
else:
response = "HTTP/1.1 403 Forbidden\r\n"
response += "Content-Length: 0\r\n"
response += "Vary: Origin\r\n"
response += "\r\n"
writer.write(response.encode(encoding="utf-8"))
writer.write(Response(403, ["Vary: Origin"], b"").render(self))
await writer.drain()
else:
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(
method=method, path=path, headers=headers, body=body, app=self
method=method, path=path, headers=headers, body=body
),
**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()
except Exception as e:
raise e
print(f"Internal Server Error: {e}")
finally:
writer.close()
@@ -258,51 +315,26 @@ class App:
await server.serve_forever()
def HTTPResponse(
request: Request,
content: str,
status=200,
content_type="text/plain; charset=utf-8",
headers=[],
) -> bytes:
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*\}\}")
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):
file = Path(file)
content: str = file.read_text(encoding="utf-8")
for m in _value_pattern.findall(content):
if m in variables:
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:
return HTTPResponse(
request, json.dumps(d), status=status, content_type="text/json; charset=utf-8"
)
def redirect(location: str): # TODO Move to another module
return Response(307, ["Location: {location}"], b"")
def JSONAPI(func):
def JSONAPI(func): # TODO Move to another module
async def wrapper(*args, **kwargs):
result = await func(*args, **kwargs)
if not isinstance(result, dict):
@@ -314,6 +346,6 @@ def JSONAPI(func):
):
return JSONResponse(result[1], result[0])
raise RuntimeError("Return value of JSONAPI route is not a dictionary")
return JSONResponse(args[0], result)
return JSONResponse(result)
return wrapper