mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Implement HTTP request routing
This commit is contained in:
parent
15b10d56ca
commit
bc30b08b2b
176
src/calibre/srv/routes.py
Normal file
176
src/calibre/srv/routes.py
Normal file
@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python2
|
||||
# vim:fileencoding=utf-8
|
||||
from __future__ import (unicode_literals, division, absolute_import,
|
||||
print_function)
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
|
||||
import httplib, sys, inspect, re
|
||||
from itertools import izip
|
||||
from operator import attrgetter
|
||||
|
||||
from calibre.srv.errors import HTTPSimpleResponse, HTTPNotFound, RouteError
|
||||
|
||||
default_methods = frozenset(('HEAD', 'GET'))
|
||||
|
||||
def route_key(route):
|
||||
return route.partition('{')[0].rstrip('/')
|
||||
|
||||
def endpoint(route, methods=default_methods, types=None):
|
||||
def annotate(f):
|
||||
f.route = route.rstrip('/') or '/'
|
||||
f.types = types or {}
|
||||
f.methods = methods
|
||||
f.is_endpoint = True
|
||||
return f
|
||||
return annotate
|
||||
|
||||
class Route(object):
|
||||
|
||||
var_pat = None
|
||||
|
||||
def __init__(self, endpoint_):
|
||||
if self.var_pat is None:
|
||||
Route.var_pat = self.var_pat = re.compile(r'{(.+?)}')
|
||||
self.endpoint = endpoint_
|
||||
del endpoint_
|
||||
if not self.endpoint.route.startswith('/'):
|
||||
raise RouteError('A route must start with /, %s does not' % self.endpoint.route)
|
||||
parts = filter(None, self.endpoint.route.split('/'))
|
||||
matchers = self.matchers = []
|
||||
self.defaults = {}
|
||||
found_optional_part = False
|
||||
self.soak_up_extra = False
|
||||
self.type_checkers = self.endpoint.types.copy()
|
||||
def route_error(msg):
|
||||
return RouteError('%s is not valid: %s' % (self.endpoint.route, msg))
|
||||
|
||||
for i, p in enumerate(parts):
|
||||
if p[0] == '{':
|
||||
if p[-1] != '}':
|
||||
raise route_error('Invalid route, variable components must be in a {}')
|
||||
name = p[1:-1]
|
||||
is_sponge = name.startswith('+')
|
||||
if is_sponge:
|
||||
if p is not parts[-1]:
|
||||
raise route_error('Can only specify + in the last component')
|
||||
name = name[1:]
|
||||
|
||||
if '=' in name:
|
||||
found_optional_part = i
|
||||
name, default = name.partition('=')[::2]
|
||||
if '{' in default or '}' in default:
|
||||
raise route_error('The characters {} are not allowed in default values')
|
||||
default = self.defaults[name] = eval(default)
|
||||
if isinstance(default, (int, long, float)):
|
||||
self.type_checkers[name] = type(default)
|
||||
if is_sponge and not isinstance(default, type('')):
|
||||
raise route_error('Soak up path component must have a default value of string type')
|
||||
else:
|
||||
if found_optional_part is not False:
|
||||
raise route_error('Cannot have non-optional path components after optional ones')
|
||||
if is_sponge:
|
||||
self.soak_up_extra = name
|
||||
matchers.append((name, True))
|
||||
else:
|
||||
if found_optional_part is not False:
|
||||
raise route_error('Cannot have non-optional path components after optional ones')
|
||||
matchers.append((None, p.__eq__))
|
||||
self.names = [n for n, m in matchers if n is not None]
|
||||
self.required_names = frozenset(self.names) - frozenset(self.defaults)
|
||||
argspec = inspect.getargspec(self.endpoint)
|
||||
if len(self.names) + 2 != len(argspec.args) - len(argspec.defaults or ()):
|
||||
raise route_error('Function must take %d non-default arguments' % (len(self.names) + 2))
|
||||
if argspec.args[2:len(self.names)+2] != self.names:
|
||||
raise route_error('Function\'s argument names do not match the variable names in the route')
|
||||
if not frozenset(self.type_checkers).issubset(frozenset(self.names)):
|
||||
raise route_error('There exist type checkers that do not correspond to route variables')
|
||||
self.min_size = found_optional_part if found_optional_part is not False else len(matchers)
|
||||
self.max_size = sys.maxsize if self.soak_up_extra else len(matchers)
|
||||
|
||||
def matches(self, path):
|
||||
args_map = self.defaults.copy()
|
||||
num = 0
|
||||
for component, (name, matched) in izip(path, self.matchers):
|
||||
num += 1
|
||||
if matched is True:
|
||||
args_map[name] = component
|
||||
elif not matched(component):
|
||||
return False
|
||||
if self.soak_up_extra and num < len(path):
|
||||
args_map[self.soak_up_extra] += '/' + '/'.join(path[num:])
|
||||
num = len(path)
|
||||
if num < len(path):
|
||||
return False
|
||||
def check(tc, val):
|
||||
try:
|
||||
return tc(val)
|
||||
except Exception:
|
||||
raise HTTPNotFound('Argument of incorrect type')
|
||||
for name, tc in self.type_checkers.iteritems():
|
||||
args_map[name] = check(tc, args_map[name])
|
||||
return (args_map[name] for name in self.names)
|
||||
|
||||
def url_for(self, **kwargs):
|
||||
not_spec = self.required_names - frozenset(kwargs)
|
||||
if not_spec:
|
||||
raise RouteError('The required variable(s) %s were not specified for the route: %s' % (','.join(not_spec), self.endpoint.route))
|
||||
args = self.defaults.copy()
|
||||
args.update(kwargs)
|
||||
route = self.var_pat.sub(lambda m:'{%s}' % m.group(1).partition('=')[0].lstrip('+'), self.endpoint.route)
|
||||
return route.format(**args)
|
||||
|
||||
def __str__(self):
|
||||
return self.endpoint.route
|
||||
__unicode__ = __repr__ = __str__
|
||||
|
||||
|
||||
class Router(object):
|
||||
|
||||
def __init__(self, ctx=None, url_prefix=None):
|
||||
self.routes = {}
|
||||
self.url_prefix = url_prefix or ''
|
||||
self.ctx = ctx
|
||||
|
||||
def add(self, endpoint):
|
||||
key = route_key(endpoint.route)
|
||||
if key in self.routes:
|
||||
raise RouteError('A route with the key: %s already exists as: %s' % (key, self.routes[key]))
|
||||
self.routes[key] = Route(endpoint)
|
||||
|
||||
def __iter__(self):
|
||||
return self.routes.itervalues()
|
||||
|
||||
def finalize(self):
|
||||
try:
|
||||
lsz = max(len(r.matchers) for r in self)
|
||||
except ValueError:
|
||||
lsz = 0
|
||||
self.min_size_map = {sz:frozenset(r for r in self if r.min_size <= sz) for sz in xrange(lsz + 1)}
|
||||
self.max_size_map = {sz:frozenset(r for r in self if r.max_size >= sz) for sz in xrange(lsz + 1)}
|
||||
self.soak_routes = sorted(frozenset(r for r in self if r.soak_up_extra), key=attrgetter('min_size'), reverse=True)
|
||||
|
||||
def find_route(self, path):
|
||||
size = len(path)
|
||||
# routes for which min_size <= size <= max_size
|
||||
routes = self.max_size_map.get(size, set()) & self.min_size_map.get(size, set())
|
||||
for route in sorted(routes, key=attrgetter('max_size'), reverse=True):
|
||||
args = route.matches(path)
|
||||
if args is not False:
|
||||
return route.endpoint, args
|
||||
for route in self.soak_routes:
|
||||
if route.min_size <= size:
|
||||
args = route.matches(path)
|
||||
if args is not False:
|
||||
return route.endpoint, args
|
||||
raise HTTPNotFound()
|
||||
|
||||
def dispatch(self, data):
|
||||
endpoint_, args = self.find_route(data.path)
|
||||
if data.method not in endpoint_.methods:
|
||||
raise HTTPSimpleResponse(httplib.METHOD_NOT_ALLOWED)
|
||||
return endpoint_(self.ctx, data, *args)
|
||||
|
||||
def url_for(self, route, **kwargs):
|
||||
return self.url_prefix + self.routes[route].url_for(**kwargs)
|
93
src/calibre/srv/tests/routes.py
Normal file
93
src/calibre/srv/tests/routes.py
Normal file
@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python2
|
||||
# vim:fileencoding=utf-8
|
||||
from __future__ import (unicode_literals, division, absolute_import,
|
||||
print_function)
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
|
||||
from calibre.srv.tests.base import BaseTest
|
||||
|
||||
class TestRouter(BaseTest):
|
||||
|
||||
def test_route_construction(self):
|
||||
' Test route construction '
|
||||
from calibre.srv.routes import Route, endpoint, RouteError
|
||||
def makeroute(route, func=lambda c,d:None, **kwargs):
|
||||
return Route(endpoint(route, **kwargs)(func))
|
||||
|
||||
r = makeroute('/')
|
||||
self.ae(r.matchers, [])
|
||||
self.assertFalse(r.required_names)
|
||||
r = makeroute('/slash/')
|
||||
self.ae(r.endpoint.route, '/slash')
|
||||
|
||||
def emr(route):
|
||||
self.assertRaises(RouteError, makeroute, route)
|
||||
emr('no_start')
|
||||
emr('/{xxx')
|
||||
emr('/{+all}/{other}')
|
||||
emr('/{t=}}')
|
||||
emr('/{+all=1}')
|
||||
emr('/{d=1}/no')
|
||||
emr('/x/{a=1}')
|
||||
self.assertRaises(RouteError, makeroute, '/a/b', lambda c,d,b,a:None)
|
||||
|
||||
def test_route_finding(self):
|
||||
'Test route finding'
|
||||
from calibre.srv.routes import Router, endpoint, HTTPNotFound
|
||||
router = Router()
|
||||
|
||||
def find(path):
|
||||
path = filter(None, path.split('/'))
|
||||
ep, args = router.find_route(path)
|
||||
args = list(args)
|
||||
return ep, args
|
||||
|
||||
@endpoint('/')
|
||||
def root(ctx, data):
|
||||
pass
|
||||
|
||||
@endpoint('/defval/{a=1}')
|
||||
def defval(ctx, data, a):
|
||||
pass
|
||||
|
||||
@endpoint('/varpath/{a}/{b}')
|
||||
def varpath(ctx, data, a, b):
|
||||
pass
|
||||
|
||||
@endpoint('/soak/{+rest}')
|
||||
def soak(ctx, dest, rest):
|
||||
pass
|
||||
|
||||
@endpoint('/soak_opt/{+rest="xxx"}')
|
||||
def soak_opt(ctx, dest, rest):
|
||||
pass
|
||||
|
||||
for x in locals().itervalues():
|
||||
if getattr(x, 'is_endpoint', False):
|
||||
router.add(x)
|
||||
router.finalize()
|
||||
|
||||
ep, args = find('/')
|
||||
self.ae(ep, root), self.assertFalse(args)
|
||||
ep, args = find('/defval')
|
||||
self.ae(ep, defval), self.ae(args, [1])
|
||||
ep, args = find('/defval/2')
|
||||
self.ae(ep, defval), self.ae(args, [2])
|
||||
self.assertRaises(HTTPNotFound, find, '/defval/a') # a must be an integer
|
||||
self.assertRaises(HTTPNotFound, find, '/varpath')
|
||||
self.assertRaises(HTTPNotFound, find, '/varpath/x')
|
||||
self.assertRaises(HTTPNotFound, find, '/varpath/x/y/z')
|
||||
self.assertRaises(HTTPNotFound, find, '/soak')
|
||||
ep, args = find('/varpath/x/y')
|
||||
self.ae(ep, varpath), self.ae(args, ['x', 'y'])
|
||||
ep, args = find('/soak/x')
|
||||
self.ae(ep, soak), self.ae(args, ['x'])
|
||||
self.ae(router.routes['/soak'].soak_up_extra, 'rest')
|
||||
ep, args = find('/soak/x/y/z')
|
||||
self.ae(ep, soak), self.ae(args, ['x/y/z'])
|
||||
ep, args = find('/soak_opt')
|
||||
self.ae(ep, soak_opt), self.ae(args, ['xxx'])
|
||||
ep, args = find('/soak_opt/a/b')
|
||||
self.ae(ep, soak_opt), self.ae(args, ['a/b'])
|
Loading…
x
Reference in New Issue
Block a user