Initial introduction of sanic-routing
This commit is contained in:
		
							
								
								
									
										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) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Adam Hopkins
					Adam Hopkins