%PDF- %PDF-
Direktori : /lib/calibre/calibre/srv/ |
Current File : //lib/calibre/calibre/srv/routes.py |
#!/usr/bin/env python3 __license__ = 'GPL v3' __copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>' import sys, inspect, re, time, numbers, json as jsonlib, textwrap from operator import attrgetter from calibre.srv.errors import HTTPSimpleResponse, HTTPNotFound, RouteError from calibre.srv.utils import http_date from calibre.utils.serialize import msgpack_dumps, json_dumps, MSGPACK_MIME from polyglot.builtins import iteritems, itervalues from polyglot import http_client from polyglot.urllib import quote as urlquote default_methods = frozenset(('HEAD', 'GET')) def json(ctx, rd, endpoint, output): rd.outheaders.set('Content-Type', 'application/json; charset=UTF-8', replace_all=True) if isinstance(output, bytes) or hasattr(output, 'fileno'): ans = output # Assume output is already UTF-8 encoded json else: ans = json_dumps(output) return ans def msgpack(ctx, rd, endpoint, output): rd.outheaders.set('Content-Type', MSGPACK_MIME, replace_all=True) if isinstance(output, bytes) or hasattr(output, 'fileno'): ans = output # Assume output is already msgpack encoded else: ans = msgpack_dumps(output) return ans def msgpack_or_json(ctx, rd, endpoint, output): accept = rd.inheaders.get('Accept', all=True) func = msgpack if MSGPACK_MIME in accept else json return func(ctx, rd, endpoint, output) json.loads, json.dumps = jsonlib.loads, jsonlib.dumps def route_key(route): return route.partition('{')[0].rstrip('/') def endpoint(route, methods=default_methods, types=None, auth_required=True, android_workaround=False, # Manage the HTTP caching # Set to None or 'no-cache' to prevent caching of this endpoint # Set to a number to cache for at most number hours # Set to a tuple (cache_type, max_age) to explicitly set the # Cache-Control header cache_control=False, # The HTTP code to be used when no error occurs. By default it is # 200 for GET and HEAD and 201 for POST ok_code=None, postprocess=None, # Needs write access to the calibre database needs_db_write=False ): from calibre.srv.handler import Context from calibre.srv.http_response import RequestData def annotate(f): f.route = route.rstrip('/') or '/' f.route_key = route_key(f.route) f.types = types or {} f.methods = methods f.auth_required = auth_required f.android_workaround = android_workaround f.cache_control = cache_control f.postprocess = postprocess f.ok_code = ok_code f.is_endpoint = True f.needs_db_write = needs_db_write argspec = inspect.getfullargspec(f) if len(argspec.args) < 2: raise TypeError('The endpoint %r must take at least two arguments' % f.route) f.__annotations__ = { argspec.args[0]: Context, argspec.args[1]: RequestData, } f.__doc__ = textwrap.dedent(f.__doc__ or '') + '\n\n' + ( (':type %s: calibre.srv.handler.Context\n' % argspec.args[0]) + (':type %s: calibre.srv.http_response.RequestData\n' % argspec.args[1]) ) return f return annotate class Route: 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 = list(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(f'{self.endpoint.route} is not valid: {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, numbers.Number): self.type_checkers[name] = type(default) if is_sponge and not isinstance(default, str): 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.all_names = frozenset(self.names) self.required_names = self.all_names - frozenset(self.defaults) argspec = inspect.getfullargspec(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: %r' % (set(self.type_checkers) - set(self.names))) 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 zip(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 iteritems(self.type_checkers): args_map[name] = check(tc, args_map[name]) return (args_map[name] for name in self.names) def url_for(self, **kwargs): names = frozenset(kwargs) not_spec = self.required_names - names if not_spec: raise RouteError('The required variable(s) {} were not specified for the route: {}'.format(','.join(not_spec), self.endpoint.route)) unknown = names - self.all_names if unknown: raise RouteError('The variable(s) {} are not part of the route: {}'.format(','.join(unknown), self.endpoint.route)) def quoted(x): if not isinstance(x, (str, bytes)): x = str(x) if isinstance(x, str): x = x.encode('utf-8') return urlquote(x, '') args = {k:'' for k in self.defaults} args.update(kwargs) args = {k:quoted(v) for k, v in iteritems(args)} route = self.var_pat.sub(lambda m:'{%s}' % m.group(1).partition('=')[0].lstrip('+'), self.endpoint.route) return route.format(**args).rstrip('/') def __str__(self): return self.endpoint.route __unicode__ = __repr__ = __str__ class Router: def __init__(self, endpoints=None, ctx=None, url_prefix=None, auth_controller=None): self.routes = {} self.url_prefix = (url_prefix or '').rstrip('/') self.strip_path = None if self.url_prefix: if not self.url_prefix.startswith('/'): self.url_prefix = '/' + self.url_prefix self.strip_path = tuple(self.url_prefix[1:].split('/')) self.ctx = ctx self.auth_controller = auth_controller self.init_session = getattr(ctx, 'init_session', lambda ep, data:None) self.finalize_session = getattr(ctx, 'finalize_session', lambda ep, data, output:None) self.endpoints = set() if endpoints is not None: self.load_routes(endpoints) self.finalize() def add(self, endpoint): if endpoint in self.endpoints: return key = endpoint.route_key if key in self.routes: raise RouteError(f'A route with the key: {key} already exists as: {self.routes[key]}') self.routes[key] = Route(endpoint) self.endpoints.add(endpoint) def load_routes(self, items): for item in items: if getattr(item, 'is_endpoint', False) is True: self.add(item) def __iter__(self): return itervalues(self.routes) 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 range(lsz + 1)} self.max_size_map = {sz:frozenset(r for r in self if r.max_size >= sz) for sz in range(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): if self.strip_path is not None and path[:len(self.strip_path)] == self.strip_path: path = path[len(self.strip_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 read_cookies(self, data): data.cookies = c = {} for cval in data.inheaders.get('Cookie', all=True): if isinstance(cval, bytes): cval = cval.decode('utf-8', 'replace') for x in cval.split(';'): x = x.strip() if x: k, v = x.partition('=')[::2] if k: # Since we only set simple hex encoded cookies, we dont # need more sophisticated value parsing c[k] = v.strip('"') def dispatch(self, data): endpoint_, args = self.find_route(data.path) if data.method not in endpoint_.methods: raise HTTPSimpleResponse(http_client.METHOD_NOT_ALLOWED) self.read_cookies(data) if endpoint_.auth_required and self.auth_controller is not None: self.auth_controller(data, endpoint_) if endpoint_.ok_code is not None: data.status_code = endpoint_.ok_code self.init_session(endpoint_, data) if endpoint_.needs_db_write: self.ctx.check_for_write_access(data) ans = endpoint_(self.ctx, data, *args) self.finalize_session(endpoint_, data, ans) outheaders = data.outheaders pp = endpoint_.postprocess if pp is not None: ans = pp(self.ctx, data, endpoint_, ans) cc = endpoint_.cache_control if cc is not False and 'Cache-Control' not in data.outheaders: if cc is None or cc == 'no-cache': outheaders['Expires'] = http_date(10000.0) # A date in the past outheaders['Cache-Control'] = 'no-cache, must-revalidate' outheaders['Pragma'] = 'no-cache' elif isinstance(cc, numbers.Number): cc = int(60 * 60 * cc) outheaders['Cache-Control'] = 'public, max-age=%d' % cc if cc == 0: cc -= 100000 outheaders['Expires'] = http_date(cc + time.time()) else: ctype, max_age = cc max_age = int(60 * 60 * max_age) outheaders['Cache-Control'] = '%s, max-age=%d' % (ctype, max_age) if max_age == 0: max_age -= 100000 outheaders['Expires'] = http_date(max_age + time.time()) return ans def url_for(self, route, **kwargs): if route is None: return self.url_prefix or '/' route = getattr(route, 'route_key', route) return self.url_prefix + self.routes[route].url_for(**kwargs)