Skip to content

Commit

Permalink
add simple static file handler
Browse files Browse the repository at this point in the history
  • Loading branch information
modularizer committed Feb 2, 2024
1 parent 3d095c9 commit d1a40bc
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 43 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ pip install socketwrench

### Serve a class
```python
from socketwrench import serve
from socketwrench import serve, StaticFileHandler

class MyServer:
src = StaticFileHandler(Path(__file__).parent.parent.parent)

def hello(self):
return "world"

Expand Down Expand Up @@ -94,8 +96,8 @@ Add a custom function to handle any requests that don't match any other routes.
# Planned Features
* [ ] Implement nesting / recursion to serve deeper routes and/or multiple classes
* [ ] Enforce OpenAPI spec with better error responses
* [ ] Serve static folders
* [ ] Make a better playground for testing endpoints
* [x] Serve static folders
* [x] Make a better playground for testing endpoints
* [ ] Make a client-side python proxy object to make API requests from python
* [ ] Test on ESP32 and other microcontrollers
* [ ] Ideas? Let me know!
Expand Down
2 changes: 1 addition & 1 deletion src/socketwrench/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from .server import Server
from .handlers import RouteHandler
from .handlers import RouteHandler, StaticFileHandler, MatchableHandlerABC
from .types import (
Request,
Response,
Expand Down
140 changes: 101 additions & 39 deletions src/socketwrench/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from socketwrench.tags import tag, get, gettag
from socketwrench.types import Request, Response, Query, Body, Route, FullPath, Method, File, ClientAddr, \
HTTPStatusCode, ErrorResponse, Headers, ErrorModes, FileResponse
HTTPStatusCode, ErrorResponse, Headers, ErrorModes, FileResponse, HTMLResponse

logger = logging.getLogger("socketwrench")

Expand Down Expand Up @@ -287,15 +287,64 @@ def wrapper(request: Request, route_params: dict = None) -> Response:
is_wrapped=True,
sig=getattr(parser, "sig", inspect.signature(_handler)),
autofill=getattr(parser, "autofill", {}), **_handler.__dict__)

if hasattr(_handler, "match"):
tag(wrapper, match=_handler.match)
return wrapper


class MatchableHandlerABC:
def match(self, route: str) -> bool:
raise NotImplementedError

def __call__(self, request: Request) -> Response:
raise NotImplementedError


class StaticFileHandler(MatchableHandlerABC):
is_wrapped = True
allowed_methods = ["GET", "HEAD"]

def __init__(self, path: Path | str, route: str = None):
self.path = path
self.route = route or "/" + path.name
self.allowed_methods = ["GET", "HEAD"]

def match(self, route: str) -> bool:
if not route.startswith(self.route):
print("route doesn't start with", self.route, route)
return False
added = route[len(self.route):]
p = (self.path / added.strip("/")) if added else self.path
if not p.exists():
print("path doesn't exist", p, route, added)
return False
return True

def __call__(self, request: Request) -> Response:
route = request.path.route()
if not route.startswith(self.route):
return Response(b"Not Found", status_code=404, version=request.version)
added = route[len(self.route):]
p = (self.path / added.strip("/")) if added else self.path

if p.is_dir() and (p / "index.html").exists():
p = p / "index.html"
if not p.exists():
return Response(b"Not Found", status_code=404, version=request.version)
elif p.is_dir():
folder_contents = list(p.iterdir())
contents = "<!DOCTYPE html><html><body><ul>" + "\n".join([f"<li><a href='{route}/{f.name}'>{f.name}</a></li>" for f in folder_contents]) + "</ul></body></html>"
return Response(contents.encode(), version=request.version)
r = FileResponse(p, version=request.version)
print("content type", r.headers.get("Content-Type"))
return r

class RouteHandler:
default_favicon = Path(__file__).parent.parent / "resources" / "favicon.ico"

def __init__(self,
routes: dict | None = None,
variable_routes: dict | None = None,
fallback_handler=None,
base_path: str = "/",
require_tag: bool = False,
Expand All @@ -307,13 +356,15 @@ def __init__(self,
self.error_mode = error_mode
self.favicon_path = favicon

self.routes = routes if isinstance(routes, dict) else {}
self.variadic_routes = variable_routes if isinstance(variable_routes, dict) else {}
for k in self.routes.copy():
if "{" in k and "}" in k:
self.variadic_routes[k] = self.routes.pop(k)
if routes and not isinstance(routes, dict):
self.parse_routes_from_object(routes)
self.routes = {}
self.matchable_routes = {}
self.variadic_routes = {}
if routes:
if isinstance(routes, dict):
for k, v in routes.items():
self[k] = v
else:
self.parse_routes_from_object(routes)
self.fallback_handler = wrap_handler(fallback_handler) if fallback_handler else None

op = wrap_handler(self.openapi, error_mode=error_mode)
Expand Down Expand Up @@ -364,13 +415,13 @@ def playground_js(self) -> Path:

@get
def playground_panels_js(self) -> Path:
return Path(__file__).parent.parent / "resources" / "playground" / "panels.js"
return Path(__file__).parent.parent / "resources" / "playground" / "panels.js"

def parse_routes_from_object(self, obj):
for k in dir(obj):
if not k.startswith("_"):
v = getattr(obj, k)
if callable(v) and not isinstance(v, type):
if callable(v):
if self.require_tag and not hasattr(v, "allowed_methods"):
continue
if getattr(v, "do_not_serve", False):
Expand All @@ -385,52 +436,61 @@ def __call__(self, request: Request) -> Response:
handler = self.routes.get(route, None)
route_params = {}
if handler is None:
if route in self.default_routes:
handler = self.default_routes[route]
print("matchable_routes", self.matchable_routes)
for k, v in self.matchable_routes.items():
if v.match(route):
handler = v
break
else:
# check all variadic routes
route_parts = route.split("/")
n = len(route_parts)
for k, v in self.variadic_routes.items():
# these are in format /a/{b}/c/{d}/e, convert to regexp groups
parts = k.split("/")
if n != len(parts):
continue
if all((route_parts[i] == parts[i]) or (parts[i].startswith("{") and parts[i].endswith("}")) for i in range(n)):
handler = v
route_params = {
parts[i][1:-1]: route_parts[i]
for i in range(n)
if parts[i].startswith("{") and parts[i].endswith("}")
}
break
if route in self.default_routes:
handler = self.default_routes[route]
else:
handler = self.fallback_handler
print(route, handler)
# check all variadic routes
route_parts = route.split("/")
n = len(route_parts)
for k, v in self.variadic_routes.items():
# these are in format /a/{b}/c/{d}/e, convert to regexp groups
parts = k.split("/")
if n != len(parts):
continue
if all((route_parts[i] == parts[i]) or (parts[i].startswith("{") and parts[i].endswith("}")) for i in range(n)):
handler = v
route_params = {
parts[i][1:-1]: route_parts[i]
for i in range(n)
if parts[i].startswith("{") and parts[i].endswith("}")
}
break
else:
handler = self.fallback_handler

if handler is None:
# send a response with 404
return Response(b'Not Found',
status_code=404,
headers={"Content-Type": "text/plain"},
version=request.version)
allowed_methods = gettag(handler, "allowed_methods", None)
print(route, handler, handler.__dict__, allowed_methods)
# if allowed_methods is None:
# print(handler, handler.__dict__)
if request.method == "HEAD" and "GET" in allowed_methods:
allowed_methods = list(allowed_methods) + ["HEAD"]
if allowed_methods is None or request.method not in allowed_methods:
print("Method Not Allowed", route, request.method, allowed_methods, handler)
return Response(b'Method Not Allowed',
status_code=405,
headers={"Content-Type": "text/plain"},
version=request.version)
if handler is None:
# send a response with 404
return Response(b'Not Found',
status_code=404,
headers={"Content-Type": "text/plain"},
version=request.version)
if route_params:
r = handler(request, route_params)
else:
r = handler(request)
return r

def route(self, handler, route: str | None = None, allowed_methods: tuple[str] | None = None):
if isinstance(handler, Path):
handler = StaticFileHandler(Path, route)

if isinstance(handler, str):
return lambda handler: self.route(handler, route, allowed_methods)

Expand All @@ -445,6 +505,8 @@ def route(self, handler, route: str | None = None, allowed_methods: tuple[str] |
route = route[1:]
if "{" in route and "}" in route:
self.variadic_routes[self.base_path + route] = h
elif hasattr(h, "match") and callable(h.match):
self.matchable_routes[self.base_path + route] = h
else:
self.routes[self.base_path + route] = h

Expand Down Expand Up @@ -480,4 +542,4 @@ def __setitem__(self, key, value):
self.route(value, key)

def __getattr__(self, item):
return self.__class__(self.fallback_handler, self.routes, self.base_path + item + "/")
return self.__class__(self.fallback_handler, self.routes, self.base_path + item + "/")
3 changes: 3 additions & 0 deletions src/socketwrench/samples/sample.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import logging
from pathlib import Path

from socketwrench.handlers import StaticFileHandler
from socketwrench.tags import private, post, put, patch, delete, route, methods

logging.basicConfig(level=logging.DEBUG)


class Sample:
src = StaticFileHandler(Path(__file__).parent.parent.parent)

def hello(self):
"""A simple hello world function."""
return "world"
Expand Down
4 changes: 4 additions & 0 deletions src/socketwrench/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,10 @@ def get_content_type(self, suffix: str):
return "text/x-asm"
elif suffix == "bat":
return "text/x-batch"
elif suffix == "toml":
return "application/toml"
elif suffix in ["in", "ini", "cfg"]:
return "text"
else:
return "application/octet-stream"

Expand Down

0 comments on commit d1a40bc

Please sign in to comment.