Initial introduction of sanic-routing
This commit is contained in:
parent
c32e7fd678
commit
792a72bdf4
34
sanic/app.py
34
sanic/app.py
|
@ -20,7 +20,12 @@ from sanic.blueprint_group import BlueprintGroup
|
|||
from sanic.blueprints import Blueprint
|
||||
from sanic.config import BASE_LOGO, Config
|
||||
from sanic.constants import HTTP_METHODS
|
||||
from sanic.exceptions import SanicException, ServerError, URLBuildError
|
||||
from sanic.exceptions import (
|
||||
NotFound,
|
||||
SanicException,
|
||||
ServerError,
|
||||
URLBuildError,
|
||||
)
|
||||
from sanic.handlers import ErrorHandler, ListenerType, MiddlewareType
|
||||
from sanic.log import LOGGING_CONFIG_DEFAULTS, error_logger, logger
|
||||
from sanic.request import Request
|
||||
|
@ -67,7 +72,9 @@ class Sanic:
|
|||
|
||||
self.name = name
|
||||
self.asgi = False
|
||||
self.router = router or Router(self)
|
||||
self.router = router or Router(
|
||||
exception=NotFound, method_handler_exception=NotFound
|
||||
)
|
||||
self.request_class = request_class
|
||||
self.error_handler = error_handler or ErrorHandler()
|
||||
self.config = Config(load_env=load_env)
|
||||
|
@ -206,17 +213,15 @@ class Sanic:
|
|||
if stream:
|
||||
handler.is_stream = stream
|
||||
|
||||
routes.extend(
|
||||
self.router.add(
|
||||
uri=uri,
|
||||
methods=methods,
|
||||
handler=handler,
|
||||
host=host,
|
||||
strict_slashes=strict_slashes,
|
||||
version=version,
|
||||
name=name,
|
||||
ignore_body=ignore_body,
|
||||
)
|
||||
self.router.add(
|
||||
uri=uri,
|
||||
methods=methods,
|
||||
handler=handler,
|
||||
host=host,
|
||||
strict_slashes=strict_slashes,
|
||||
version=version,
|
||||
name=name,
|
||||
ignore_body=ignore_body,
|
||||
)
|
||||
return routes, handler
|
||||
|
||||
|
@ -1321,6 +1326,9 @@ class Sanic:
|
|||
auto_reload=False,
|
||||
):
|
||||
"""Helper function used by `run` and `create_server`."""
|
||||
|
||||
self.router.finalize()
|
||||
|
||||
if isinstance(ssl, dict):
|
||||
# try common aliaseses
|
||||
cert = ssl.get("cert") or ssl.get("certificate")
|
||||
|
|
509
sanic/router.py
509
sanic/router.py
|
@ -1,133 +1,29 @@
|
|||
import re
|
||||
import uuid
|
||||
|
||||
from collections import defaultdict, namedtuple
|
||||
from collections.abc import Iterable
|
||||
from functools import lru_cache
|
||||
from urllib.parse import unquote
|
||||
|
||||
from sanic.exceptions import MethodNotSupported, NotFound
|
||||
from sanic.views import CompositionView
|
||||
from sanic_routing import BaseRouter
|
||||
|
||||
from sanic.constants import HTTP_METHODS
|
||||
from sanic.log import logger
|
||||
from sanic.request import Request
|
||||
|
||||
|
||||
Route = namedtuple(
|
||||
"Route",
|
||||
[
|
||||
"handler",
|
||||
"methods",
|
||||
"pattern",
|
||||
"parameters",
|
||||
"name",
|
||||
"uri",
|
||||
"endpoint",
|
||||
"ignore_body",
|
||||
],
|
||||
)
|
||||
Parameter = namedtuple("Parameter", ["name", "cast"])
|
||||
class Router(BaseRouter):
|
||||
DEFAULT_METHOD = "GET"
|
||||
ALLOWED_METHODS = HTTP_METHODS
|
||||
|
||||
REGEX_TYPES = {
|
||||
"string": (str, r"[^/]+"),
|
||||
"int": (int, r"-?\d+"),
|
||||
"number": (float, r"-?(?:\d+(?:\.\d*)?|\.\d+)"),
|
||||
"alpha": (str, r"[A-Za-z]+"),
|
||||
"path": (str, r"[^/].*?"),
|
||||
"uuid": (
|
||||
uuid.UUID,
|
||||
r"[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-"
|
||||
r"[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}",
|
||||
),
|
||||
}
|
||||
@lru_cache
|
||||
def get(self, request: Request):
|
||||
route, handler, params = self.resolve(
|
||||
path=request.path,
|
||||
method=request.method,
|
||||
)
|
||||
|
||||
ROUTER_CACHE_SIZE = 1024
|
||||
# TODO: Implement response
|
||||
# - args,
|
||||
# - endpoint,
|
||||
# - ignore_body,
|
||||
|
||||
|
||||
def url_hash(url):
|
||||
return url.count("/")
|
||||
|
||||
|
||||
class RouteExists(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RouteDoesNotExist(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ParameterNameConflicts(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Router:
|
||||
"""Router supports basic routing with parameters and method checks
|
||||
|
||||
Usage:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@sanic.route('/my/url/<my_param>', methods=['GET', 'POST', ...])
|
||||
def my_route(request, my_param):
|
||||
do stuff...
|
||||
|
||||
or
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@sanic.route('/my/url/<my_param:my_type>', methods['GET', 'POST', ...])
|
||||
def my_route_with_type(request, my_param: my_type):
|
||||
do stuff...
|
||||
|
||||
Parameters will be passed as keyword arguments to the request handling
|
||||
function. Provided parameters can also have a type by appending :type to
|
||||
the <parameter>. Given parameter must be able to be type-casted to this.
|
||||
If no type is provided, a string is expected. A regular expression can
|
||||
also be passed in as the type. The argument given to the function will
|
||||
always be a string, independent of the type.
|
||||
"""
|
||||
|
||||
routes_static = None
|
||||
routes_dynamic = None
|
||||
routes_always_check = None
|
||||
parameter_pattern = re.compile(r"<(.+?)>")
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.routes_all = {}
|
||||
self.routes_names = {}
|
||||
self.routes_static_files = {}
|
||||
self.routes_static = {}
|
||||
self.routes_dynamic = defaultdict(list)
|
||||
self.routes_always_check = []
|
||||
self.hosts = set()
|
||||
|
||||
@classmethod
|
||||
def parse_parameter_string(cls, parameter_string):
|
||||
"""Parse a parameter string into its constituent name, type, and
|
||||
pattern
|
||||
|
||||
For example::
|
||||
|
||||
parse_parameter_string('<param_one:[A-z]>')` ->
|
||||
('param_one', str, '[A-z]')
|
||||
|
||||
:param parameter_string: String to parse
|
||||
:return: tuple containing
|
||||
(parameter_name, parameter_type, parameter_pattern)
|
||||
"""
|
||||
# We could receive NAME or NAME:PATTERN
|
||||
name = parameter_string
|
||||
pattern = "string"
|
||||
if ":" in parameter_string:
|
||||
name, pattern = parameter_string.split(":", 1)
|
||||
if not name:
|
||||
raise ValueError(
|
||||
f"Invalid parameter syntax: {parameter_string}"
|
||||
)
|
||||
|
||||
default = (str, pattern)
|
||||
# Pull from pre-configured types
|
||||
_type, pattern = REGEX_TYPES.get(pattern, default)
|
||||
|
||||
return name, _type, pattern
|
||||
return handler, (), params, route.path, route.name, None, False
|
||||
|
||||
def add(
|
||||
self,
|
||||
|
@ -140,364 +36,9 @@ class Router:
|
|||
version=None,
|
||||
name=None,
|
||||
):
|
||||
"""Add a handler to the route list
|
||||
|
||||
:param uri: path to match
|
||||
:param methods: sequence of accepted method names. If none are
|
||||
provided, any method is allowed
|
||||
:param handler: request handler function.
|
||||
When executed, it should provide a response object.
|
||||
:param strict_slashes: strict to trailing slash
|
||||
:param ignore_body: Handler should not read the body, if any
|
||||
:param version: current version of the route or blueprint. See
|
||||
docs for further details.
|
||||
:return: Nothing
|
||||
"""
|
||||
routes = []
|
||||
if version is not None:
|
||||
version = re.escape(str(version).strip("/").lstrip("v"))
|
||||
uri = "/".join([f"/v{version}", uri.lstrip("/")])
|
||||
# add regular version
|
||||
routes.append(
|
||||
self._add(uri, methods, handler, host, name, ignore_body)
|
||||
)
|
||||
|
||||
if strict_slashes:
|
||||
return routes
|
||||
|
||||
if not isinstance(host, str) and host is not None:
|
||||
# we have gotten back to the top of the recursion tree where the
|
||||
# host was originally a list. By now, we've processed the strict
|
||||
# slashes logic on the leaf nodes (the individual host strings in
|
||||
# the list of host)
|
||||
return routes
|
||||
|
||||
# Add versions with and without trailing /
|
||||
slashed_methods = self.routes_all.get(uri + "/", frozenset({}))
|
||||
unslashed_methods = self.routes_all.get(uri[:-1], frozenset({}))
|
||||
if isinstance(methods, Iterable):
|
||||
_slash_is_missing = all(
|
||||
method in slashed_methods for method in methods
|
||||
)
|
||||
_without_slash_is_missing = all(
|
||||
method in unslashed_methods for method in methods
|
||||
)
|
||||
else:
|
||||
_slash_is_missing = methods in slashed_methods
|
||||
_without_slash_is_missing = methods in unslashed_methods
|
||||
|
||||
slash_is_missing = not uri[-1] == "/" and not _slash_is_missing
|
||||
without_slash_is_missing = (
|
||||
uri[-1] == "/" and not _without_slash_is_missing and not uri == "/"
|
||||
)
|
||||
# add version with trailing slash
|
||||
if slash_is_missing:
|
||||
routes.append(
|
||||
self._add(uri + "/", methods, handler, host, name, ignore_body)
|
||||
)
|
||||
# add version without trailing slash
|
||||
elif without_slash_is_missing:
|
||||
routes.append(
|
||||
self._add(uri[:-1], methods, handler, host, name, ignore_body)
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
def _add(
|
||||
self, uri, methods, handler, host=None, name=None, ignore_body=False
|
||||
):
|
||||
"""Add a handler to the route list
|
||||
|
||||
:param uri: path to match
|
||||
:param methods: sequence of accepted method names. If none are
|
||||
provided, any method is allowed
|
||||
:param handler: request handler function.
|
||||
When executed, it should provide a response object.
|
||||
:param name: user defined route name for url_for
|
||||
:return: Nothing
|
||||
"""
|
||||
if host is not None:
|
||||
if isinstance(host, str):
|
||||
uri = host + uri
|
||||
self.hosts.add(host)
|
||||
|
||||
else:
|
||||
if not isinstance(host, Iterable):
|
||||
raise ValueError(
|
||||
f"Expected either string or Iterable of "
|
||||
f"host strings, not {host!r}"
|
||||
)
|
||||
|
||||
for host_ in host:
|
||||
self.add(uri, methods, handler, host_, name)
|
||||
return
|
||||
|
||||
# Dict for faster lookups of if method allowed
|
||||
if methods:
|
||||
methods = frozenset(methods)
|
||||
|
||||
parameters = []
|
||||
parameter_names = set()
|
||||
properties = {"unhashable": None}
|
||||
|
||||
def add_parameter(match):
|
||||
name = match.group(1)
|
||||
name, _type, pattern = self.parse_parameter_string(name)
|
||||
|
||||
if name in parameter_names:
|
||||
raise ParameterNameConflicts(
|
||||
f"Multiple parameter named <{name}> " f"in route uri {uri}"
|
||||
)
|
||||
parameter_names.add(name)
|
||||
|
||||
parameter = Parameter(name=name, cast=_type)
|
||||
parameters.append(parameter)
|
||||
|
||||
# Mark the whole route as unhashable if it has the hash key in it
|
||||
if re.search(r"(^|[^^]){1}/", pattern):
|
||||
properties["unhashable"] = True
|
||||
# Mark the route as unhashable if it matches the hash key
|
||||
elif re.search(r"/", pattern):
|
||||
properties["unhashable"] = True
|
||||
|
||||
return f"({pattern})"
|
||||
|
||||
pattern_string = re.sub(self.parameter_pattern, add_parameter, uri)
|
||||
pattern = re.compile(fr"^{pattern_string}$")
|
||||
|
||||
def merge_route(route, methods, handler):
|
||||
# merge to the existing route when possible.
|
||||
if not route.methods or not methods:
|
||||
# method-unspecified routes are not mergeable.
|
||||
raise RouteExists(f"Route already registered: {uri}")
|
||||
elif route.methods.intersection(methods):
|
||||
# already existing method is not overloadable.
|
||||
duplicated = methods.intersection(route.methods)
|
||||
duplicated_methods = ",".join(list(duplicated))
|
||||
|
||||
raise RouteExists(
|
||||
f"Route already registered: {uri} [{duplicated_methods}]"
|
||||
)
|
||||
if isinstance(route.handler, CompositionView):
|
||||
view = route.handler
|
||||
else:
|
||||
view = CompositionView()
|
||||
view.add(route.methods, route.handler)
|
||||
view.add(methods, handler)
|
||||
route = route._replace(
|
||||
handler=view, methods=methods.union(route.methods)
|
||||
)
|
||||
return route
|
||||
|
||||
if parameters:
|
||||
# TODO: This is too complex, we need to reduce the complexity
|
||||
if properties["unhashable"]:
|
||||
routes_to_check = self.routes_always_check
|
||||
ndx, route = self.check_dynamic_route_exists(
|
||||
pattern, routes_to_check, parameters
|
||||
)
|
||||
else:
|
||||
routes_to_check = self.routes_dynamic[url_hash(uri)]
|
||||
ndx, route = self.check_dynamic_route_exists(
|
||||
pattern, routes_to_check, parameters
|
||||
)
|
||||
if ndx != -1:
|
||||
# Pop the ndx of the route, no dups of the same route
|
||||
routes_to_check.pop(ndx)
|
||||
else:
|
||||
route = self.routes_all.get(uri)
|
||||
|
||||
# prefix the handler name with the blueprint name
|
||||
# if available
|
||||
# special prefix for static files
|
||||
is_static = False
|
||||
if name and name.startswith("_static_"):
|
||||
is_static = True
|
||||
name = name.split("_static_", 1)[-1]
|
||||
|
||||
if hasattr(handler, "__blueprintname__"):
|
||||
bp_name = handler.__blueprintname__
|
||||
|
||||
handler_name = f"{bp_name}.{name or handler.__name__}"
|
||||
else:
|
||||
handler_name = name or getattr(
|
||||
handler, "__name__", handler.__class__.__name__
|
||||
)
|
||||
|
||||
if route:
|
||||
route = merge_route(route, methods, handler)
|
||||
else:
|
||||
endpoint = self.app._build_endpoint_name(handler_name)
|
||||
|
||||
route = Route(
|
||||
handler=handler,
|
||||
methods=methods,
|
||||
pattern=pattern,
|
||||
parameters=parameters,
|
||||
name=handler_name,
|
||||
uri=uri,
|
||||
endpoint=endpoint,
|
||||
ignore_body=ignore_body,
|
||||
)
|
||||
|
||||
self.routes_all[uri] = route
|
||||
if is_static:
|
||||
pair = self.routes_static_files.get(handler_name)
|
||||
if not (pair and (pair[0] + "/" == uri or uri + "/" == pair[0])):
|
||||
self.routes_static_files[handler_name] = (uri, route)
|
||||
|
||||
else:
|
||||
pair = self.routes_names.get(handler_name)
|
||||
if not (pair and (pair[0] + "/" == uri or uri + "/" == pair[0])):
|
||||
self.routes_names[handler_name] = (uri, route)
|
||||
|
||||
if properties["unhashable"]:
|
||||
self.routes_always_check.append(route)
|
||||
elif parameters:
|
||||
self.routes_dynamic[url_hash(uri)].append(route)
|
||||
else:
|
||||
self.routes_static[uri] = route
|
||||
return route
|
||||
|
||||
@staticmethod
|
||||
def check_dynamic_route_exists(pattern, routes_to_check, parameters):
|
||||
"""
|
||||
Check if a URL pattern exists in a list of routes provided based on
|
||||
the comparison of URL pattern and the parameters.
|
||||
|
||||
:param pattern: URL parameter pattern
|
||||
:param routes_to_check: list of dynamic routes either hashable or
|
||||
unhashable routes.
|
||||
:param parameters: List of :class:`Parameter` items
|
||||
:return: Tuple of index and route if matching route exists else
|
||||
-1 for index and None for route
|
||||
"""
|
||||
for ndx, route in enumerate(routes_to_check):
|
||||
if route.pattern == pattern and route.parameters == parameters:
|
||||
return ndx, route
|
||||
else:
|
||||
return -1, None
|
||||
|
||||
@lru_cache(maxsize=ROUTER_CACHE_SIZE)
|
||||
def find_route_by_view_name(self, view_name, name=None):
|
||||
"""Find a route in the router based on the specified view name.
|
||||
|
||||
:param view_name: string of view name to search by
|
||||
:param kwargs: additional params, usually for static files
|
||||
:return: tuple containing (uri, Route)
|
||||
"""
|
||||
if not view_name:
|
||||
return (None, None)
|
||||
|
||||
if view_name == "static" or view_name.endswith(".static"):
|
||||
return self.routes_static_files.get(name, (None, None))
|
||||
|
||||
return self.routes_names.get(view_name, (None, None))
|
||||
|
||||
def get(self, request):
|
||||
"""Get a request handler based on the URL of the request, or raises an
|
||||
error
|
||||
|
||||
:param request: Request object
|
||||
:return: handler, arguments, keyword arguments
|
||||
"""
|
||||
# No virtual hosts specified; default behavior
|
||||
if not self.hosts:
|
||||
return self._get(request.path, request.method, "")
|
||||
# virtual hosts specified; try to match route to the host header
|
||||
|
||||
try:
|
||||
return self._get(
|
||||
request.path, request.method, request.headers.get("Host", "")
|
||||
)
|
||||
# try default hosts
|
||||
except NotFound:
|
||||
return self._get(request.path, request.method, "")
|
||||
|
||||
def get_supported_methods(self, url):
|
||||
"""Get a list of supported methods for a url and optional host.
|
||||
|
||||
:param url: URL string (including host)
|
||||
:return: frozenset of supported methods
|
||||
"""
|
||||
route = self.routes_all.get(url)
|
||||
# if methods are None then this logic will prevent an error
|
||||
return getattr(route, "methods", None) or frozenset()
|
||||
|
||||
@lru_cache(maxsize=ROUTER_CACHE_SIZE)
|
||||
def _get(self, url, method, host):
|
||||
"""Get a request handler based on the URL of the request, or raises an
|
||||
error. Internal method for caching.
|
||||
|
||||
:param url: request URL
|
||||
:param method: request method
|
||||
:return: handler, arguments, keyword arguments
|
||||
"""
|
||||
url = unquote(host + url)
|
||||
# Check against known static routes
|
||||
route = self.routes_static.get(url)
|
||||
method_not_supported = MethodNotSupported(
|
||||
f"Method {method} not allowed for URL {url}",
|
||||
method=method,
|
||||
allowed_methods=self.get_supported_methods(url),
|
||||
)
|
||||
|
||||
if route:
|
||||
if route.methods and method not in route.methods:
|
||||
raise method_not_supported
|
||||
match = route.pattern.match(url)
|
||||
else:
|
||||
route_found = False
|
||||
# Move on to testing all regex routes
|
||||
for route in self.routes_dynamic[url_hash(url)]:
|
||||
match = route.pattern.match(url)
|
||||
route_found |= match is not None
|
||||
# Do early method checking
|
||||
if match and method in route.methods:
|
||||
break
|
||||
else:
|
||||
# Lastly, check against all regex routes that cannot be hashed
|
||||
for route in self.routes_always_check:
|
||||
match = route.pattern.match(url)
|
||||
route_found |= match is not None
|
||||
# Do early method checking
|
||||
if match and method in route.methods:
|
||||
break
|
||||
else:
|
||||
# Route was found but the methods didn't match
|
||||
if route_found:
|
||||
raise method_not_supported
|
||||
raise NotFound(f"Requested URL {url} not found")
|
||||
|
||||
kwargs = {
|
||||
p.name: p.cast(value)
|
||||
for value, p in zip(match.groups(1), route.parameters)
|
||||
}
|
||||
route_handler = route.handler
|
||||
if hasattr(route_handler, "handlers"):
|
||||
route_handler = route_handler.handlers[method]
|
||||
|
||||
return (
|
||||
route_handler,
|
||||
[],
|
||||
kwargs,
|
||||
route.uri,
|
||||
route.name,
|
||||
route.endpoint,
|
||||
route.ignore_body,
|
||||
)
|
||||
|
||||
def is_stream_handler(self, request):
|
||||
"""Handler for request is stream or not.
|
||||
:param request: Request object
|
||||
:return: bool
|
||||
"""
|
||||
try:
|
||||
handler = self.get(request)[0]
|
||||
except (NotFound, MethodNotSupported):
|
||||
return False
|
||||
if hasattr(handler, "view_class") and hasattr(
|
||||
handler.view_class, request.method.lower()
|
||||
):
|
||||
handler = getattr(handler.view_class, request.method.lower())
|
||||
return hasattr(handler, "is_stream")
|
||||
# TODO: Implement
|
||||
# - host
|
||||
# - strict_slashes
|
||||
# - version
|
||||
# - ignore_body
|
||||
super().add(path=uri, handler=handler, methods=methods, name=name)
|
||||
|
|
Loading…
Reference in New Issue
Block a user