Compare commits

...

25 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
0880
014cdd80e2 Update HTTPResponse major changes 2026-01-17 18:14:20 +03:30
0880
cc89a32eae Remove old CORS handling 2026-01-17 18:13:57 +03:30
0880
57b0d69eae Handle preflight 2026-01-17 18:13:48 +03:30
0880
ba149b2d47 Add PATCH and HEAD remove OPTIONS 2026-01-17 18:13:32 +03:30
0880
b35ab73daa add CORS settings 2026-01-17 18:13:09 +03:30
0880
1e610fbc9d Formatting 2026-01-17 18:13:01 +03:30
0880
46ebe53f71 Update request to include app 2026-01-17 18:12:51 +03:30
0880
569d400baf __contains__ method for Headers class 2026-01-17 18:12:34 +03:30
0880
83353522db Add CORS class 2026-01-17 18:12:18 +03:30
4 changed files with 252 additions and 61 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,14 +1,28 @@
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:
Origins: set[str]
Methods: set[str]
Disabled: bool
def __init__(self):
self.Disabled = False
self.Origins: set[str] = {}
self.Methods: set[str] = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"}
class Headers: class Headers:
def __init__(self): def __init__(self):
self._d: dict[str, str] = {} self._d: dict[str, str] = {}
@@ -22,6 +36,9 @@ class Headers:
def __str__(self): def __str__(self):
return str(self._d) return str(self._d)
def __contains__(self, key):
return self._d.__contains__(key)
class Request: class Request:
def __init__(self, method: str, path: str, headers: Headers, body: bytes): def __init__(self, method: str, path: str, headers: Headers, body: bytes):
@@ -40,44 +57,55 @@ 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,
} }
self.CORS = CORS()
def _pattern_to_regex(self, temp) -> re.Pattern[str]: def _pattern_to_regex(self, temp) -> re.Pattern[str]:
re_temp = temp re_temp = temp
iter = PR.finditer(temp) iter = PR.finditer(temp)
for m in iter: for m in iter:
name = m[1] name = m[1]
re_temp = re.sub( re_temp = re.sub(m[0], r"(?P<" + name + r">[a-zA-Z0-9\-._~:%&=]+)", re_temp)
m[0], r"(?P<" + name + r">[a-zA-Z0-9\-._~:%&=]+)", re_temp
)
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] = {}
@@ -88,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
@@ -97,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
@@ -106,24 +142,33 @@ 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
return decorator return decorator
def OPTIONS(self, path: str): def PATCH(self, path: str):
"""Decorator to register a OPTIONS 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, "OPTIONS", func) self._serve(path, "PATCH", func)
return func
return decorator
def HEAD(self, path: str):
"""Decorator to register a HEAD HTTP route."""
def decorator(func: Callable[[Request, ...], Awaitable[Response | None]]):
self._serve(path, "HEAD", func)
return func return func
return decorator return decorator
@@ -137,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
): ):
@@ -157,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:
@@ -179,21 +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""
route, kwargs = self.resolve(path, method) 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")
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",
]
response = await route( writer.write(Response(200, head, b"").render(self))
request=Request(
method=method,
path=path,
headers=headers,
body=body,
),
**kwargs,
)
writer.write(response)
await writer.drain() await writer.drain()
else:
writer.write(Response(403, ["Vary: Origin"], b"").render(self))
await writer.drain()
else:
route, kwargs = self.resolve(path, method)
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
),
**kwargs,
)
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: except Exception as e:
raise e
print(f"Internal Server Error: {e}") print(f"Internal Server Error: {e}")
finally: finally:
writer.close() writer.close()
@@ -209,25 +315,12 @@ class App:
await server.serve_forever() await server.serve_forever()
AccessControlAllowOrigin = "*"
def HTTPResponse(
content: str, status=200, content_type="text/plain; charset=utf-8", headers=[]
) -> bytes:
global AccessControlAllowOrigin
head: str = f"HTTP/1.1 {status} {http.client.responses.get(status, 'Unkown Status Code')}\r\nContent-Type: {content_type}\r\n"
head += f"Access-Control-Allow-Origin: {AccessControlAllowOrigin}\r\n" # CORS
head += "Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS\r\n" # CORS
head += "\r\n".join(headers) + ("\r\n" if len(headers) > 0 else "")
head += "\r\n"
return (head + content).encode(encoding="utf-8")
_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(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")
@@ -237,13 +330,11 @@ def render(file: str | Path, variables: dict[str, Any] = {}) -> bytes:
return HTTPResponse(content, content_type="text/html; charset=utf-8") return HTTPResponse(content, content_type="text/html; charset=utf-8")
def JSONResponse(d: dict, status=200) -> bytes: def redirect(location: str): # TODO Move to another module
return HTTPResponse( return Response(307, ["Location: {location}"], b"")
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):