# -*- coding: utf-8 -*- from contextlib import contextmanager import inspect import sys import json import datetime import tornado.ioloop import tornado.web import tornado.gen import tornado.concurrent from concurrent.futures import ThreadPoolExecutor from tornado.concurrent import run_on_executor import logging logger = logging.getLogger(__name__) MAX_WORKERS = 4 def json_response(data=None, status=200, text='ok'): if not data: data = {} return {'status': {'code': status, 'text': text}, 'data': data} def _to_json(python_object): if isinstance(python_object, datetime.datetime): if python_object.year < 1900: tt = python_object.timetuple() return '%d-%02d-%02dT%02d:%02d%02dZ' % tuple(list(tt)[:6]) return python_object.strftime('%Y-%m-%dT%H:%M:%SZ') raise TypeError('%s %s is not JSON serializable' % (repr(python_object), type(python_object))) def json_dumps(obj): indent = 2 return json.dumps(obj, indent=indent, default=_to_json, ensure_ascii=False).encode() def trim(docstring): if not docstring: return '' # Convert tabs to spaces (following the normal Python rules) # and split into a list of lines: lines = docstring.expandtabs().splitlines() # Determine minimum indentation (first line doesn't count): indent = sys.maxsize for line in lines[1:]: stripped = line.lstrip() if stripped: indent = min(indent, len(line) - len(stripped)) # Remove indentation (first line is special): trimmed = [lines[0].strip()] if indent < sys.maxsize: for line in lines[1:]: trimmed.append(line[indent:].rstrip()) # Strip off trailing and leading blank lines: while trimmed and not trimmed[-1]: trimmed.pop() while trimmed and not trimmed[0]: trimmed.pop(0) # Return a single string: return '\n'.join(trimmed) @contextmanager def defaultcontext(): yield class ApiHandler(tornado.web.RequestHandler): executor = ThreadPoolExecutor(max_workers=MAX_WORKERS) def initialize(self, context=None, public=False): self._context = context self._public = public def get(self): self.write('use POST') @run_on_executor def api_task(self, request): import settings context = self._context if context is None: context = defaultcontext action = None if request.headers.get('Content-Type') == 'application/json': try: r = json.loads(request.body.decode()) action = r['action'] data = r['data'] except: action = None if not action: action = request.arguments.get('action', [None])[0].decode('utf-8') data = request.arguments.get('data', [b'{}'])[0] data = json.loads(data.decode('utf-8')) if data else {} if not action: methods = list(actions.keys()) api = [] for f in sorted(methods): api.append({'name': f, 'doc': actions.doc(f).replace('\n', '
\n')}) response = json_response(api) else: if settings.DEBUG_API: logger.debug('API %s %s', action, data) if self._public: f = actions.versions['public'].get(action) else: f = actions.get(action) if f: with context(): try: response = f(data) except: logger.debug('FAILED %s %s', action, data, exc_info=True) response = json_response(status=500, text='%s failed' % action) else: response = json_response(status=400, text='Unknown action %s' % action) return response @tornado.gen.coroutine def post(self): if 'origin' in self.request.headers and self.request.host not in self.request.headers['origin']: logger.debug('reject cross site attempt to access api %s', self.request) self.set_status(403) self.write('') return response = yield self.api_task(self.request) if not 'status' in response: response = json_response(response) response = json_dumps(response) self.set_header('Content-Type', 'application/json') self.write(response) class ApiActions(dict): properties = {} versions = {} def _api(self, data, version=None): ''' returns list of all known api actions takes { docs: bool } if docs is true, action properties contain docstrings returns { actions: { 'api': { cache: true, doc: 'recursion' }, 'hello': { cache: true, .. } ... } } ''' data = data or {} docs = data.get('docs', False) code = data.get('code', False) if version: _actions = list(self.versions[version].keys()) else: _actions = list(self.keys()) _actions.sort() actions = {} for a in _actions: actions[a] = self.properties[a] if docs: actions[a]['doc'] = self.doc(a, version) if code: actions[a]['code'] = self.code(a, version) return {'actions': actions} def __init__(self): self.register(self._api, 'api') def doc(self, name, version=None): if version: f = self.versions[version][name] else: f = self[name] return trim(f.__doc__) def code(self, name, version=None): if version: f = self.versions[version][name] else: f = self[name] if name != 'api' and hasattr(f, 'func_closure') and f.__closure__: fc = [c for c in f.__closure__ if hasattr(c.cell_contents, '__call__')] f = fc[len(fc)-1].cell_contents info = f.__code__.co_filename info = '%s:%s' % (info, f.__code__.co_firstlineno) return info, trim(inspect.getsource(f)) def register(self, method, action=None, cache=True, version=None): if not action: action = method.__name__ if version: if version not in self.versions: self.versions[version] = {} self.register(lambda data: self._api(data, version), action='api', version=version) self.versions[version][action] = method else: self[action] = method self.properties[action] = {'cache': cache} def unregister(self, action): if action in self: del self[action] actions = ApiActions()