sanic/sanic/router.py

428 lines
15 KiB
Python
Raw Normal View History

2016-10-15 20:59:00 +01:00
import re
2016-10-20 06:07:16 +01:00
from collections import defaultdict, namedtuple
from collections.abc import Iterable
2016-10-20 05:07:07 +01:00
from functools import lru_cache
2017-02-16 02:54:00 +00:00
from sanic.exceptions import NotFound, InvalidUsage, MethodNotSupported
2017-02-16 02:54:00 +00:00
from sanic.views import CompositionView
2016-10-15 20:59:00 +01:00
2017-02-02 18:00:15 +00:00
Route = namedtuple(
'Route',
2017-04-28 20:06:59 +01:00
['handler', 'methods', 'pattern', 'parameters', 'name', 'uri'])
2017-02-02 18:00:15 +00:00
Parameter = namedtuple('Parameter', ['name', 'cast'])
2016-10-15 20:59:00 +01:00
2016-10-20 04:43:31 +01:00
REGEX_TYPES = {
2016-10-20 05:05:55 +01:00
'string': (str, r'[^/]+'),
2016-10-20 04:56:23 +01:00
'int': (int, r'\d+'),
'number': (float, r'[0-9\\.]+'),
2016-10-20 05:05:55 +01:00
'alpha': (str, r'[A-Za-z]+'),
2017-04-13 04:34:35 +01:00
'path': (str, r'[^/].*?'),
2016-10-20 04:43:31 +01:00
}
ROUTER_CACHE_SIZE = 1024
2016-10-15 20:59:00 +01:00
2016-10-20 06:07:16 +01:00
def url_hash(url):
return url.count('/')
class RouteExists(Exception):
pass
2016-10-20 06:07:16 +01:00
class RouteDoesNotExist(Exception):
pass
2016-10-15 20:59:00 +01:00
class Router:
"""Router supports basic routing with parameters and method checks
2016-10-15 20:59:00 +01:00
Usage:
.. code-block:: python
2017-01-18 21:48:54 +00:00
@sanic.route('/my/url/<my_param>', methods=['GET', 'POST', ...])
def my_route(request, my_param):
2016-10-15 20:59:00 +01:00
do stuff...
or
.. code-block:: python
2017-01-18 21:48:54 +00:00
@sanic.route('/my/url/<my_param:my_type>', methods['GET', 'POST', ...])
def my_route_with_type(request, my_param: my_type):
do stuff...
2016-10-15 20:59:00 +01:00
2016-10-16 14:01:59 +01:00
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.
2016-10-15 20:59:00 +01:00
"""
routes_static = None
routes_dynamic = None
routes_always_check = None
2017-02-02 17:21:14 +00:00
parameter_pattern = re.compile(r'<(.+?)>')
2016-10-15 20:59:00 +01:00
def __init__(self):
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()
2016-10-15 20:59:00 +01:00
2017-04-13 04:34:35 +01:00
@classmethod
def parse_parameter_string(cls, parameter_string):
"""Parse a parameter string into its constituent name, type, and
pattern
2017-03-16 05:52:18 +00:00
For example::
parse_parameter_string('<param_one:[A-z]>')` ->
('param_one', str, '[A-z]')
2017-02-02 18:00:15 +00:00
:param parameter_string: String to parse
:return: tuple containing
(parameter_name, parameter_type, parameter_pattern)
"""
2017-02-02 17:21:14 +00:00
# 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:
2017-10-09 15:58:04 +01:00
raise ValueError(
"Invalid parameter syntax: {}".format(parameter_string)
)
2017-02-02 17:21:14 +00:00
default = (str, pattern)
# Pull from pre-configured types
_type, pattern = REGEX_TYPES.get(pattern, default)
return name, _type, pattern
2017-07-13 04:18:56 +01:00
def add(self, uri, methods, handler, host=None, strict_slashes=False,
2017-08-21 11:05:34 +01:00
version=None, name=None):
2017-07-02 07:46:34 +01:00
"""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
2017-07-13 04:18:56 +01:00
:param version: current version of the route or blueprint. See
docs for further details.
2017-07-02 07:46:34 +01:00
:return: Nothing
"""
2017-07-13 04:18:56 +01:00
if version is not None:
if uri.startswith('/'):
uri = "/".join(["/v{}".format(str(version)), uri[1:]])
else:
uri = "/".join(["/v{}".format(str(version)), uri])
2017-02-20 23:58:27 +00:00
# add regular version
2017-08-21 11:05:34 +01:00
self._add(uri, methods, handler, host, name)
2017-03-16 04:11:45 +00:00
if strict_slashes:
return
# Add versions with and without trailing /
slashed_methods = self.routes_all.get(uri + '/', frozenset({}))
if isinstance(methods, Iterable):
_slash_is_missing = all(method in slashed_methods for
method in methods)
else:
_slash_is_missing = methods in slashed_methods
2017-02-28 03:54:58 +00:00
slash_is_missing = (
not uri[-1] == '/' and not _slash_is_missing
2017-02-28 03:54:58 +00:00
)
without_slash_is_missing = (
uri[-1] == '/' and not
self.routes_all.get(uri[:-1], False) and not
uri == '/'
2017-02-28 03:54:58 +00:00
)
2017-02-20 23:58:27 +00:00
# add version with trailing slash
if slash_is_missing:
2017-08-21 11:05:34 +01:00
self._add(uri + '/', methods, handler, host, name)
2017-02-20 23:58:27 +00:00
# add version without trailing slash
elif without_slash_is_missing:
2017-08-21 11:05:34 +01:00
self._add(uri[:-1], methods, handler, host, name)
2017-02-20 23:58:27 +00:00
2017-08-21 11:05:34 +01:00
def _add(self, uri, methods, handler, host=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 name: user defined route name for url_for
2016-10-15 20:59:00 +01:00
:return: Nothing
"""
2017-01-08 23:48:12 +00:00
if host is not None:
2017-01-19 03:40:20 +00:00
if isinstance(host, str):
uri = host + uri
self.hosts.add(host)
2017-01-19 03:40:20 +00:00
else:
if not isinstance(host, Iterable):
raise ValueError("Expected either string or Iterable of "
"host strings, not {!r}".format(host))
for host_ in host:
2017-08-21 11:05:34 +01:00
self.add(uri, methods, handler, host_, name)
2017-01-19 03:40:20 +00:00
return
2016-10-15 20:59:00 +01:00
# Dict for faster lookups of if method allowed
2016-10-16 14:01:59 +01:00
if methods:
2016-10-20 05:16:16 +01:00
methods = frozenset(methods)
2016-10-15 20:59:00 +01:00
parameters = []
properties = {"unhashable": None}
2016-10-15 20:59:00 +01:00
def add_parameter(match):
2016-10-20 06:33:59 +01:00
name = match.group(1)
2017-02-02 17:21:14 +00:00
name, _type, pattern = self.parse_parameter_string(name)
parameter = Parameter(
2017-02-02 18:00:15 +00:00
name=name, cast=_type)
parameters.append(parameter)
# Mark the whole route as unhashable if it has the hash key in it
2017-04-13 04:34:35 +01:00
if re.search(r'(^|[^^]){1}/', pattern):
properties['unhashable'] = True
# Mark the route as unhashable if it matches the hash key
2017-04-13 04:34:35 +01:00
elif re.search(r'/', pattern):
properties['unhashable'] = True
2016-10-20 06:33:59 +01:00
return '({})'.format(pattern)
2016-10-15 20:59:00 +01:00
2017-02-02 17:21:14 +00:00
pattern_string = re.sub(self.parameter_pattern, add_parameter, uri)
2016-10-20 04:56:23 +01:00
pattern = re.compile(r'^{}$'.format(pattern_string))
2016-10-15 20:59:00 +01:00
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(
"Route already registered: {}".format(uri))
elif route.methods.intersection(methods):
# already existing method is not overloadable.
duplicated = methods.intersection(route.methods)
raise RouteExists(
"Route already registered: {} [{}]".format(
uri, ','.join(list(duplicated))))
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)
else:
routes_to_check = self.routes_dynamic[url_hash(uri)]
ndx, route = self.check_dynamic_route_exists(
pattern, routes_to_check)
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__'):
handler_name = '{}.{}'.format(
handler.__blueprintname__, name or handler.__name__)
else:
handler_name = name or getattr(handler, '__name__', None)
if route:
route = merge_route(route, methods, handler)
else:
route = Route(
handler=handler, methods=methods, pattern=pattern,
2017-04-28 20:06:59 +01:00
parameters=parameters, name=handler_name, uri=uri)
2016-10-20 06:07:16 +01:00
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
2016-10-15 20:59:00 +01:00
@staticmethod
def check_dynamic_route_exists(pattern, routes_to_check):
for ndx, route in enumerate(routes_to_check):
if route.pattern == pattern:
return ndx, route
else:
return -1, None
2017-01-08 23:48:12 +00:00
def remove(self, uri, clean_cache=True, host=None):
if host is not None:
uri = host + uri
try:
route = self.routes_all.pop(uri)
for handler_name, pairs in self.routes_names.items():
if pairs[0] == uri:
self.routes_names.pop(handler_name)
break
for handler_name, pairs in self.routes_static_files.items():
if pairs[0] == uri:
self.routes_static_files.pop(handler_name)
break
except KeyError:
raise RouteDoesNotExist("Route was not registered: {}".format(uri))
if route in self.routes_always_check:
self.routes_always_check.remove(route)
elif url_hash(uri) in self.routes_dynamic \
and route in self.routes_dynamic[url_hash(uri)]:
self.routes_dynamic[url_hash(uri)].remove(route)
else:
self.routes_static.pop(uri)
if clean_cache:
self._get.cache_clear()
2017-02-02 17:21:14 +00:00
@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.
2017-02-02 18:00:15 +00:00
:param view_name: string of view name to search by
:param kwargs: additional params, usually for static files
2017-02-02 18:00:15 +00:00
:return: tuple containing (uri, Route)
"""
2017-02-03 15:12:33 +00:00
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))
2017-02-02 17:21:14 +00:00
2016-10-15 20:59:00 +01:00
def get(self, request):
"""Get a request handler based on the URL of the request, or raises an
2016-10-16 14:01:59 +01:00
error
2016-10-15 20:59:00 +01:00
:param request: Request object
:return: handler, arguments, keyword arguments
"""
2017-02-21 00:36:48 +00:00
# No virtual hosts specified; default behavior
if not self.hosts:
2017-03-03 16:44:50 +00:00
return self._get(request.path, request.method, '')
2017-02-21 00:36:48 +00:00
# virtual hosts specified; try to match route to the host header
try:
2017-03-03 16:44:50 +00:00
return self._get(request.path, request.method,
2017-01-08 23:48:12 +00:00
request.headers.get("Host", ''))
2017-02-21 00:36:48 +00:00
# try default hosts
except NotFound:
2017-03-03 16:44:50 +00:00
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)
return getattr(route, 'methods', 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
"""
2017-01-08 23:48:12 +00:00
url = host + url
# Check against known static routes
route = self.routes_static.get(url)
method_not_supported = MethodNotSupported(
'Method {} not allowed for URL {}'.format(method, 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
2016-10-20 06:07:16 +01:00
match = route.pattern.match(url)
2016-10-15 20:59:00 +01:00
else:
route_found = False
# Move on to testing all regex routes
for route in self.routes_dynamic[url_hash(url)]:
2016-10-20 06:07:16 +01:00
match = route.pattern.match(url)
route_found |= match is not None
# Do early method checking
if match and method in route.methods:
2016-10-20 06:07:16 +01:00
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('Requested URL {} not found'.format(url))
2016-10-20 06:33:59 +01:00
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]
2017-04-28 20:06:59 +01:00
return route_handler, [], kwargs, route.uri
2017-05-05 12:09:32 +01:00
def is_stream_handler(self, request):
""" Handler for request is stream or not.
:param request: Request object
:return: bool
"""
2017-06-09 16:33:34 +01:00
try:
handler = self.get(request)[0]
except (NotFound, InvalidUsage):
2017-06-09 16:33:34 +01:00
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')