Source code for willpyre.router

from typing import Any, Callable, Dict, List, Union
from copy import deepcopy
from collections import namedtuple
import traceback
import re
from .kua import Routes
from .structure import (
    HTTPException,
    Request,
    Response,
    Response405,
    Response405JSON,
    Response500,
    Response404,
    Response422JSON,
    Response404JSON,
    Response500JSON,
    Response404JSON,
    JSONResponse,
    HTMLResponse,
    HijackedMiddlewareResponse
)
from .schema import schema_repr, ValidationError
from .openapi import (
    get_swagger_ui_html,
    get_swagger_ui_oauth2_redirect_html,
    gen_openapi_schema,
)
from .common import router_config, apirouter_config

RouteData = namedtuple('RouterData', ['handler', 'middlewares', 'pass_through'])

[docs] class StaticRouter: """ StaticRouter class has the HTTP methods, paths, and handlers. Not meant for usage. Acts as a base class. """ def __init__( self, endpoint_prefix="", config: Union[None, Dict[str, Any]] = None, ): self.routes = dict() self.routes["GET"] = {} self.routes["POST"] = {} self.routes["PUT"] = {} self.routes["FETCH"] = {} self.routes["HEAD"] = {} self.routes["PATCH"] = {} self.routes["CONNECT"] = {} self.routes["OPTIONS"] = {} self.routes["TRACE"] = {} self.bodied_methods = ("POST", "PUT", "PATCH") self.ws_routes = dict() self.config = dict() self.endpoints = dict() self.endpoint_prefix = endpoint_prefix if not config: config = router_config self.config = config
[docs] def add_route( self, path: str, method: str, handler: Callable, endpoint_name: str = "", middlewares: list = [], pass_through: list = [], ) -> None: """ This should be used to add a route with respect to an HTTP method to the router. Args: self: The :class:`Router` path(str): The path to add method(str): The method to add for the router to handle. handler: The function that will be called in response to this route for the given method. endpoint_name: The name of the endpoint for this path. middlewares: The list of middleware functions to call before calling the `handler`. Default: [] pass_through: The functions to pass the request and response through after the `handler` has returned a value. """ if path[-1] != "/": path += "/" self.add_endpoint(path, endpoint_name) self.routes[method][path] = RouteData(handler, middlewares, pass_through)
[docs] def add_method(self, method: str): # pragma: no cover """ This should be used to adding custom HTTP methods to the routing dictionary Args: self: The :class:`Router` method(str): The method to add for the router to handle. Raises: NotImplementedError """ # self.routes[method] = {} raise NotImplementedError
[docs] def add_endpoint(self, route: str, name: str = ""): if not name: return if name in self.endpoints: raise RuntimeError( f"Name exists with value {name}:{self.endpoints[name]} you cannot override !!!" ) self.endpoints[self.endpoint_prefix + name] = route
[docs] def endpoint_for(self, name: str) -> str: return self.endpoints[name]
[docs] def get(self, path: str, name: str = "", **opts) -> Callable: """ This is meant to be used as a decorator on a function, that will be executed on a get query to the path. Usage:: @router.get('/'): def landing(request,response): #Some application logic return response Args: self: :class:`Router` path(str): The Request path name(str): Endpoint name, default is none. """ def decorator(handler: Callable) -> Callable: self.add_route(path=path, method="GET", handler=handler, endpoint_name=name, **opts) return handler return decorator
[docs] def post(self, path: str, name: str = "", **opts) -> Callable: """ This is meant to be used as a decorator on a function, that will be executed on a post request to the path. Args: self: :class:`Router` path(str): The Request path """ def decorator(handler: Callable) -> Callable: self.add_route( path=path, method="POST", handler=handler, endpoint_name=name, **opts ) return handler return decorator
[docs] def put(self, path: str, name: str = "", **opts) -> Callable: """ This is meant to be used as a decorator on a function, that will be executed on a put request to the path. Args: self: :class:`Router` path(str): The Request path """ def decorator(handler: Callable) -> Callable: self.add_route(path=path, method="PUT", handler=handler, endpoint_name=name, **opts) return handler return decorator
[docs] def fetch(self, path: str, name: str = "", **opts) -> Callable: """ This is meant to be used as a decorator on a function, that will be executed on a fetch request to the path. Args: self: :class:`Router` path(str): The Request path """ def decorator(handler: Callable) -> Callable: self.add_route( path=path, method="FETCH", handler=handler, endpoint_name=name, **opts ) return handler return decorator
[docs] def patch(self, path: str, name: str = "", **opts) -> Callable: """ This is meant to be used as a decorator on a function, that will be executed on a PATCH request to the path. Args: self: :class:`Router` path(str): The Request path """ def decorator(handler: Callable) -> Callable: self.add_route( path=path, method="PATCH", handler=handler, endpoint_name=name, **opts ) return handler return decorator
[docs] def connect(self, path: str, name: str = "", **opts) -> Callable: """ This is meant to be used as a decorator on a function, that will be executed on a connect request to the path. Args: self: :class:`Router` path(str): The Request path """ def decorator(handler: Callable) -> Callable: self.add_route( path=path, method="CONNECT", handler=handler, endpoint_name=name, **opts ) return handler return decorator
[docs] def options(self, path: str, name: str = "", **opts) -> Callable: """ This is meant to be used as a decorator on a function, that will be executed on an options request to the path. Args: self: :class:`Router` path(str): The Request path """ def decorator(handler: Callable) -> Callable: self.add_route( path=path, method="OPTIONS", handler=handler, endpoint_name=name, **opts ) return handler return decorator
[docs] def trace(self, path: str, name: str = "", **opts) -> Callable: """ This is meant to be used as a decorator on a function, that will be executed on a trace request to the path. Args: self: :class:`Router` path(str): The Request path """ def decorator(handler: Callable) -> Callable: self.add_route( path=path, method="TRACE", handler=handler, endpoint_name=name, **opts ) return handler return decorator
[docs] async def handle(self, request) -> Response: """ The handle function wil handle the requests and send appropriate responses, based on the functions defined. Args: request: :class:`willpyre.structure.Request` response: :class:`willpyre.structure.Response` Returns: :class:`willpyre.structure.Response` """ response = self.config.get("response", HTMLResponse()) if request.path[-1] != "/": request.path += "/" try: if request.method == "HEAD": response_ = await self.routes["GET"][request.path](request, response) else: matched_route_data = self.routes[request.method][request.path] response_ = None for middleware in matched_route_data.middlewares: if response_: (request, response_) = await middleware(request, response_) else: (request, response_) = await middleware(request, response) if isinstance(response_, HijackedMiddlewareResponse): return response_.response if response_: response_ = await matched_route_data.handler(request, response_) else: response_ = await matched_route_data.handler(request, response) for pass_ in matched_route_data.pass_through: if response_: response_ = await pass_(request, response_) else: response_ = await pass_(request, response) return response_ except KeyError: resp = Response404() return resp
[docs] class Router(StaticRouter): """The Router class handles routing of URLs. You need to give an endpoint prefix if you are embedding it.""" def __init__( self, endpoint_prefix: str = "", config: Union[None, Dict[str, Any]] = None, ): self.validation_dict = { "int": lambda var: var.isdigit(), "lcase": lambda var: var.islower(), "ucase": lambda var: var.isupper(), "alnum": lambda var: var.isalnum(), # Everything is a str "str": lambda var: True, "nomatch": lambda var: False, } self.KuaRoutes = Routes(self.validation_dict) self.WSKuaRoutes = Routes(self.validation_dict) self.embeds = {} super().__init__(endpoint_prefix)
[docs] def add_route( self, path: str, method: str, handler: Callable, endpoint_name: str = "", middlewares: list = [], pass_through: list = [], ) -> None: if path[-1] != "/": path += "/" variablized_url = self.KuaRoutes.add(path) self.add_endpoint(path, endpoint_name) self.routes[method][variablized_url] = RouteData(handler, middlewares, pass_through)
[docs] def add_ws_route(self, path: str, method: str, handler: Callable) -> None: raise NotImplementedError("You need to implement websockets.")
[docs] def embed_router(self, mount_at: str, router) -> None: if mount_at[0] == "/": mount_at = mount_at[1:] if mount_at[-1] == "/": mount_at = mount_at[:-1] router.endpoint_prefix = mount_at self.embeds[mount_at] = router try: paths = {} for path, method in router.paths.items(): paths["/" + mount_at + path] = method router.paths = paths except AttributeError: pass
[docs] async def handle(self, request: Request) -> Response: response = self.config.get("response", HTMLResponse()) if request.path[-1] != "/": request.path += "/" match = re.match("/[^/]+", request.path) if match is not None: if match.group()[1:] in self.embeds.keys(): request.path = request.path[match.span()[1] :] return await self.embeds[match.group()[1:]].handle(request) try: response_routes = self.routes[request.method] except KeyError: # pdb.set_trace() # Key errors occur on when no method is found on a route. response_ = self.config.get("405Response", Response405()) return response_ except Exception: # Catches other errors. self.config.get("logger_exception", print)(traceback.format_exc()) response_ = self.config.get("500Response", Response500()) # noqa return response_ try: # todo: add middleware suport request.params, variablized_url = self.KuaRoutes.match(request.path) matched_route_data = response_routes[variablized_url] response_ = None for middleware in matched_route_data.middlewares: if response_: (request, response_) = await middleware(request, response_) else: (request, response_) = await middleware(request, response) if isinstance(response_, HijackedMiddlewareResponse): return response_.response if response_: response_ = await matched_route_data.handler(request, response_) else: response_ = await matched_route_data.handler(request, response) for pass_ in matched_route_data.pass_through: if response_: response_ = await pass_(request, response_) else: response_ = await pass_(request, response) if isinstance(response_, HijackedMiddlewareResponse(Response)): return response_ return response_ except (HTTPException, ValidationError, KeyError) as e: response_ = self.config.get("404Response", Response404()) return response_ except Exception: # Catches other errors. self.config.get("logger_exception", print)(traceback.format_exc()) response_ = self.config.get("500Response", Response500()) return response_
[docs] async def handleWS(self, scope: dict, send, recieve) -> None: # pragma: no cover # path = scope["path"] # if path[-1] != '/': # path += '/' # try: # params, variablized_url = self.KuaRoutes.match( # scope["path"]) # await self.ws_routes[variablized_url](scope, send, recieve) # except (HTTPError, KeyError): # await self.send({"type": "websocket.close", "code": 1006}) raise NotImplementedError("You need to implement websockets, to use it.")
[docs] class OpenAPIRouter(Router): # pragma: no cover """ OpenAPIRouter class has the HTTP methods, paths, and handlers and other info required for OpenAPI based docs. Args: self: The class ``willpyre.structure.Request`` description(str): Description of the API schemes(List[str]): Default = ['http','https'] version(str): Default = "0.0.1". Version of your API. endpoint_prefix(str): prefix of paths for internal info. body(str): The HTTP request body. oauth_redirect_url(str): Default = "/openapi-rediect". tos_url(str): Default="/terms-of-service". docs_url(str): Default="/docs". tags(List[str]): Default=[]. dependencies: Default=None. swagger_params: Default=None. swagger_favicon(str): Default = "/favicon.ico". definitions(List[Any]): Default = [] license:Default=None contact:Default=None host:Default=None """ def __init__( self, config: Union[None, Dict[str, Any]] = None, description: str = "", schemes: List[str] = ["http", "https"], version: str = "0.0.1", endpoint_prefix: str = "", openapi_url: str = "/openapi.json", oauth_redirect_url: str = "/openapi-rediect", tos_url: str = "/terms-of-service", openapi_version: str = "3.0.0", title: str = "", docs_url="/docs", tags: List[str] = [], dependencies=None, swagger_params=None, swagger_favicon: str = "/favicon.ico", definitions: List[Any] = [], license=None, contact=None, host=None, ) -> None: self.openapi_url = openapi_url self.version = version self.oauth2_redirect_url = oauth_redirect_url self.tos_url = endpoint_prefix + tos_url self.openapi_version = openapi_version self.title = title self.tags = tags self.dependencies = dependencies self.docs_url = docs_url self.swagger_params = swagger_params self.paths = {} self.definitions = definitions self.license = license self.schemes = schemes self.description = description self.contact = contact self.host = host self.endpoint_prefix = endpoint_prefix if not config: config = apirouter_config self.config = config self.openapi_schema = {} if self.openapi_version == "2.0": self.openapi_base_url = "#/definitions/" elif self.openapi_version.startswith("3.0"): self.openapi_base_url = "#/components/schemas/" else: raise ValueError(f"{self.openapi_version } is an invalid version.") super().__init__(endpoint_prefix) definitions_dict = {} for model in definitions: defn: Dict[str, Any] = {"type": "object"} name = model.__name__ defn["properties"] = schema_repr(model) definitions_dict[name] = defn self.definitions_dict = definitions_dict # Init done. Post-init stuff here if self.openapi_url: async def openapi(req: Request, res: Response) -> JSONResponse: return JSONResponse(self.openapi()) self.add_route(self.openapi_url, "GET", openapi, no_docs=True) async def swagger_ui_html(req: Request, res: Response) -> HTMLResponse: openapi_url = self.endpoint_prefix + self.openapi_url oauth2_redirect_url = self.endpoint_prefix + self.oauth2_redirect_url if oauth2_redirect_url: oauth2_redirect_url = self.endpoint_prefix + oauth_redirect_url return get_swagger_ui_html( openapi_url="/" + openapi_url, title=self.title + " | Swagger UI", oauth2_redirect_url=oauth2_redirect_url, init_oauth=None, # todo:z Create some init option swagger_params=self.swagger_params, ) self.add_route(self.docs_url, "GET", swagger_ui_html, no_docs=True) if self.oauth2_redirect_url: async def swagger_ui_redirect(req: Request) -> Response: return get_swagger_ui_oauth2_redirect_html() self.add_route( self.oauth2_redirect_url, "GET", swagger_ui_redirect, no_docs=True ) self.add_route( self.oauth2_redirect_url, "POST", swagger_ui_redirect, no_docs=True ) self.add_route( self.oauth2_redirect_url, "PUT", swagger_ui_redirect, no_docs=True )
[docs] def add_route( self, path: str, method: str, handler: Callable, middlewares: list = [], pass_through: list = [], # OpenAPI stuff now endpoint_name: str = "", response_model=None, status: int = 200, deprecated: bool = False, operation_id=None, summary: str = "", openapi_extra=None, tags=[], consumes=["application/json"], produces=["application/json"], parameters=["parameters"], responses={ "200": {"description": ""}, }, security=[], path_parameters=None, auto_path_parameters=True, no_docs: bool = False, body_model=None, body_parameters=None, **kwargs, ): Router.add_route(self, path, method, handler, middlewares=middlewares, pass_through=pass_through) if no_docs: return if not path_parameters: path_parameters = [] if not body_parameters: body_parameters = [] if body_model: params = [ { "in": "body", "name": "body", "reqired": True, "schema": {"$ref": self.openapi_base_url + body_model.__name__}, } ] body_parameters += params path_ = path match = re.search(r"/:[^/]+", path_) if not match: path_parameters = [] while match: # match becomes None when there are no more occurences var = match.group()[2:] if "|" not in var: var += "|str" var, validation = var.split("|") path_ = path_[: match.span()[0]] + "/{" + var + "}/" if not auto_path_parameters: return params = [ { "name": var, "reqired": True, "type": validation, "in": "path", } ] match = re.search(r"/:[^/]+", path_) path_parameters += params description = handler.__doc__ if response_model: responses["200"]["schema"] = { "$ref": self.openapi_base_url + response_model.__name__ } self.paths[path_] = {} self.paths[path_][method.lower()] = { "tags": tags, "summary": summary, "consumes": consumes, "produces": produces, "parameters": path_parameters + body_parameters, "responses": responses, "description": description, "security": security, } del path_parameters
[docs] def add_api_route( self, path: str, handler: Callable, methods: list = ["GET", "POST"], middlewares: list = [], pass_through: list = [], # OpenAPI stuff now endpoint_name: str = "", response_model=None, status: int = 200, deprecated: bool = False, operation_id=None, summary: str = "", openapi_extra=None, tags=[], consumes=["application/json"], produces=["application/json"], parameters=["parameters"], responses={"200": {"description": ""}}, security=[], ): for method in methods: self.add_route( path, method, handler, # OpenAPI stuff now endpoint_name, response_model=response_model, status=status, deprecated=deprecated, operation_id=operation_id, summary=summary, openapi_extra=openapi_extra, tags=tags, consumes=consumes, produces=produces, parameters=parameters, responses=responses, security=[], # middleware stuff middlewares=middlewares, pass_through=pass_through, )
[docs] def get(self, path: str, name: str = "", **opts) -> Callable: """ This is meant to be used as a decorator on a function, that will be executed on a get query to the path. Usage:: @router.get('/'): def landing(request,response): #Some application logic return response Args: self: :class:`Router` path(str): The Request path name(str): Endpoint name, default is none. """ def decorator(handler: Callable) -> Callable: self.add_route( path=path, method="GET", handler=handler, endpoint_name=name, **opts ) return handler return decorator
[docs] def post(self, path: str, name: str = "", **opts) -> Callable: """ This is meant to be used as a decorator on a function, that will be executed on a post request to the path. Args: self: :class:`Router` path(str): The Request path """ def decorator(handler: Callable) -> Callable: self.add_route( path=path, method="POST", handler=handler, endpoint_name=name, **opts ) return handler return decorator
[docs] def put(self, path: str, name: str = "", **opts) -> Callable: """ This is meant to be used as a decorator on a function, that will be executed on a put request to the path. Args: self: :class:`Router` path(str): The Request path """ def decorator(handler: Callable) -> Callable: self.add_route( path=path, method="PUT", handler=handler, endpoint_name=name, **opts ) return handler return decorator
[docs] def fetch(self, path: str, name: str = "", **opts) -> Callable: """ This is meant to be used as a decorator on a function, that will be executed on a fetch request to the path. Args: self: :class:`Router` path(str): The Request path """ def decorator(handler: Callable) -> Callable: self.add_route( path=path, method="FETCH", handler=handler, endpoint_name=name, **opts ) return handler return decorator
[docs] def patch(self, path: str, name: str = "", **opts) -> Callable: """ This is meant to be used as a decorator on a function, that will be executed on a PATCH request to the path. Args: self: :class:`Router` path(str): The Request path """ def decorator(handler: Callable) -> Callable: self.add_route( path=path, method="PATCH", handler=handler, endpoint_name=name, **opts ) return handler return decorator
[docs] def connect(self, path: str, name: str = "", **opts) -> Callable: """ This is meant to be used as a decorator on a function, that will be executed on a connect request to the path. Args: self: :class:`Router` path(str): The Request path """ def decorator(handler: Callable) -> Callable: self.add_route( path=path, method="CONNECT", handler=handler, endpoint_name=name, **opts ) return handler return decorator
[docs] def options(self, path: str, name: str = "", **opts) -> Callable: """ This is meant to be used as a decorator on a function, that will be executed on an options request to the path. Args: self: :class:`Router` path(str): The Request path """ def decorator(handler: Callable) -> Callable: self.add_route( path=path, method="OPTIONS", handler=handler, endpoint_name=name, **opts ) return handler return decorator
[docs] def trace(self, path: str, name: str = "", **opts) -> Callable: """ This is meant to be used as a decorator on a function, that will be executed on a trace request to the path. Args: self: :class:`Router` path(str): The Request path """ def decorator(handler: Callable) -> Callable: self.add_route( path=path, method="TRACE", handler=handler, endpoint_name=name, **opts ) return handler return decorator
[docs] def openapi(self): if not self.openapi_schema: self.openapi_schema = gen_openapi_schema( title=self.title, version=self.version, openapi_version=self.openapi_version, description=self.description, terms_of_service=self.tos_url, license=self.license, routes=self.routes, tags=self.tags, contact=self.contact, host=self.host, paths=self.paths, definitions=self.definitions_dict, ) return self.openapi_schema
[docs] async def handle(self, request: Request) -> Response: response = self.config.get("response", JSONResponse()) if request.path[-1] != "/": request.path += "/" try: if request.method == "HEAD": response_routes = self.routes["GET"] else: response_routes = self.routes[request.method] except KeyError: # pdb.set_trace() # Key errors occur on when no method is found on a route. response_ = self.config.get("405Response", Response405JSON()) return response_ except Exception: # Catches other errors. self.config.get("logger_exception", print)(traceback.format_exc()) response_ = self.config.get("500Response", Response500JSON()) # noqa return response_ try: request.params, variablized_url = self.KuaRoutes.match(request.path) matched_route_data = response_routes[variablized_url] response_ = None for middleware in matched_route_data.middlewares: if response_: (request, response_) = await middleware(request, response_) else: (request, response_) = await middleware(request, response) if isinstance(response_, HijackedMiddlewareResponse): return response_.response if response_: response_ = await matched_route_data.handler(request, response_) else: response_ = await matched_route_data.handler(request, response) for pass_ in matched_route_data.pass_through: if response_: response_ = await pass_(request, response_) else: response_ = await pass_(request, response) if isinstance(response_, HijackedMiddlewareResponse(Response)): return response_ return response_ except ValidationError as e: response_ = self.config.get("422Response", Response422JSON()) return response_ except KeyError: response_ = self.config.get("404Response", Response404JSON()) return response_ except HTTPException: response_ = self.config.get("404Response", Response404JSON()) return response_ except Exception: # Catches other errors. self.config.get("logger_exception", print)(traceback.format_exc()) response_ = self.config.get("500Response", Response500JSON()) return response_