diff --git a/sanic/app.py b/sanic/app.py index fe6d2708..63490560 100644 --- a/sanic/app.py +++ b/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") diff --git a/sanic/router.py b/sanic/router.py index 2ef810fe..4822301a 100644 --- a/sanic/router.py +++ b/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/', methods=['GET', 'POST', ...]) - def my_route(request, my_param): - do stuff... - - or - - .. code-block:: python - - @sanic.route('/my/url/', 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 . 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', 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)