Source code for tornado_restless.handler

#!/usr/bin/python
# -*- encoding: utf-8 -*-
"""
    Tornado Restless BaseHandler

    Handles all registered blueprints, you may override this class and
     use the modification via create_api_blueprint(handler_class=...)
"""
import inspect
from json import loads
import logging
from math import ceil
from traceback import print_exception
from urllib.parse import parse_qs
import sys
import itertools

from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm.exc import UnmappedInstanceError
from sqlalchemy.util import memoized_instancemethod, memoized_property
from tornado.web import RequestHandler, HTTPError

from .convert import to_dict, to_filter
from .errors import IllegalArgumentError, MethodNotAllowedError, ProcessingException
from .wrapper import SessionedModelWrapper


__author__ = 'Martin Martimeo <martin@martimeo.de>'
__date__ = '26.04.13 - 22:09'


[docs]class BaseHandler(RequestHandler): """ Basic Blueprint for a sqlalchemy model Subclass of :class:`tornado.web.RequestHandler` that handles web requests. Overwrite :func:`get() <get>` / :func:`post() <post>` / :func:`put() <put>` / :func:`patch() <patch>` / :func:`delete() <delete>` if you want complete customize handling of the methods. Note that the default implementation of this function check for the allowness and then call depending on the instance_id parameter the associated _single / _many method, so you probably want to call super() If you just want to customize the handling of the methods overwrite method_single or method_many. If you want completly disable a method overwrite the SUPPORTED_METHODS constant """ SUPPORTED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] # noinspection PyMethodOverriding
[docs] def initialize(self, model, manager, methods: set, preprocessor: dict, postprocessor: dict, allow_patch_many: bool, allow_method_override: bool, validation_exceptions, exclude_queries: bool, exclude_hybrids: bool, include_columns: list, exclude_columns: list, results_per_page: int, max_results_per_page: int): """ Init of the handler, derives arguments from api create_api_blueprint :param model: The sqlalchemy model :param manager: The tornado_restless Api Manager :param methods: Allowed methods for this model :param preprocessor: A dictionary of preprocessor functions :param postprocessor: A dictionary of postprocessor functions :param allow_patch_many: Allow PATCH with multiple datasets :param allow_method_override: Support X-HTTP-Method-Override Header :param validation_exceptions: :param exclude_queries: Don't execude dynamic queries (like from associations or lazy relations) :param exclude_hybrids: When exclude_queries is True and exclude_hybrids is False, hybrids are still included. :param include_columns: Whitelist of columns to be included :param exclude_columns: Blacklist of columns to be excluded :param results_per_page: The default value of how many results are returned per request :param max_results_per_page: The hard upper limit of resutest per page :reqheader X-HTTP-Method-Override: If allow_method_override is True, this header overwrites the request method """ # Override Method if Header provided if allow_method_override and 'X-HTTP-Method-Override' in self.request.headers: self.request.method = self.request.headers['X-HTTP-Method-Override'] super().initialize() self.model = SessionedModelWrapper(model, manager.session_maker()) self.methods = [method.lower() for method in methods] self.allow_patch_many = allow_patch_many self.validation_exceptions = validation_exceptions self.preprocessor = preprocessor self.postprocessor = postprocessor self.results_per_page = results_per_page self.max_results_per_page = max_results_per_page self.include = self.parse_columns(include_columns) self.exclude = self.parse_columns(exclude_columns) self.to_dict_options = {'execute_queries': not exclude_queries, 'execute_hybrids': not exclude_hybrids}
def prepare(self): """ Prepare the request """ self._call_preprocessor() def on_finish(self): """ Finish the request """ self._call_postprocessor() def parse_columns(self, strings: list) -> dict: """ Parse a list of column names (name1, name2, relation.name1, ...) :param strings: List of Column Names :return: """ columns = {} # Strings if strings is None: return None # Parse for column in [column.split(".", 1) for column in strings]: if len(column) == 1: columns[column[0]] = True else: columns.setdefault(column[0], []).append(column[1]) # Now parse relations for (key, item) in columns.items(): if isinstance(item, list): columns[key] = itertools.chain.from_iterable(self.parse_columns(strings) for strings in item) # Return return columns def get_filters(self): """ Returns a list of filters made by the query argument :query filters: list of filters :query order_by: list of orderings """ # Get all provided filters argument_filters = self.get_query_argument("filters", []) # Get all provided orders argument_orders = self.get_query_argument("order_by", []) return to_filter(self.model.model, argument_filters, argument_orders) def write_error(self, status_code: int, **kwargs): """ Encodes any exceptions thrown to json SQLAlchemyError will be encoded as 400 / SQLAlchemy: Bad Request Errors from the restless api as 400 / Restless: Bad Arguments ProcessingException will be encoded with status code / ProcessingException: Stopped Processing Any other exceptions will occur as an 500 exception :param status_code: The Status Code in Response :param kwargs: Additional Parameters """ if 'exc_info' in kwargs: exc_type, exc_value = kwargs['exc_info'][:2] if status_code >= 300: print_exception(*kwargs['exc_info']) if issubclass(exc_type, UnmappedInstanceError): self.set_status(400, reason='SQLAlchemy: Unmapped Instance') self.finish(dict(type=exc_type.__module__ + "." + exc_type.__name__, message="%s" % exc_value)) elif issubclass(exc_type, SQLAlchemyError): self.set_status(400, reason='SQLAlchemy: Bad Request') self.finish(dict(type=exc_type.__module__ + "." + exc_type.__name__, message="%s" % exc_value)) elif issubclass(exc_type, IllegalArgumentError): self.set_status(400, reason='Restless: Bad Arguments') self.finish(dict(type=exc_type.__module__ + "." + exc_type.__name__, message="%s" % exc_value)) elif issubclass(exc_type, ProcessingException): self.set_status(status_code, reason='ProcessingException: %s' % (exc_value.reason or "Stopped Processing")) self.finish(dict(type=exc_type.__module__ + "." + exc_type.__name__, message="%s" % exc_value)) elif issubclass(exc_type, HTTPError) and exc_value.reason: self.set_status(status_code, reason=exc_value.reason) self.finish(dict(type=exc_type.__module__ + "." + exc_type.__name__, message="%s" % exc_value, **exc_value.__dict__)) else: super().write_error(status_code, **kwargs) else: super().write_error(status_code, **kwargs)
[docs] def patch(self, instance_id: str=None): """ PATCH (update instance) request :param instance_id: query argument of request :type instance_id: comma seperated string list :statuscode 403: PATCH MANY disallowed :statuscode 405: PATCH disallowed """ if not 'patch' in self.methods: raise MethodNotAllowedError(self.request.method) self._call_preprocessor(search_params=self.search_params) if instance_id is None: if self.allow_patch_many: result = self.patch_many() else: raise MethodNotAllowedError(self.request.method, status_code=403) else: result = self.patch_single(instance_id.split(",")) self._call_postprocessor(result=result) self.finish(result)
[docs] def patch_many(self) -> dict: """ Patch many instances :statuscode 201: instances successfull modified :query limit: limit the count of modified instances :query single: If true sqlalchemy will raise an error if zero or more than one instances would be modified """ # Flush self.model.session.flush() # Get values values = self.get_argument_values() # Filters filters = self.get_filters() # Limit limit = self.get_query_argument("limit", None) # Call Preprocessor self._call_preprocessor(filters=filters, data=values) # Modify Instances if self.get_query_argument("single", False): instances = [self.model.one(filters=filters)] for instance in instances: for (key, value) in values.items(): logging.debug("%s => %s" % (key, value)) setattr(instance, key, value) num = 1 else: num = self.model.update(values, limit=limit, filters=filters) # Commit self.model.session.commit() # Result self.set_status(201, "Patched") return {'num_modified': num}
[docs] def patch_single(self, instance_id: list) -> dict: """ Patch one instance :param instance_id: query argument of request :type instance_id: list of primary keys :statuscode 201: instance successfull modified :statuscode 404: Error """ try: with self.model.session.begin_nested(): values = self.get_argument_values() # Call Preprocessor self._call_preprocessor(instance_id=instance_id, data=values) # Get Instance instance = self.model.get(*instance_id) # Set Values for (key, value) in values.items(): self.logger.debug("%r.%s => %s" % (instance, key, value)) setattr(instance, key, value) # Flush try: self.model.session.flush() except SQLAlchemyError as ex: logging.exception(ex) self.model.session.rollback() self.send_error(status_code=400, exc_info=sys.exc_info()) return # Refresh self.model.session.refresh(instance) # Set Status self.set_status(201, "Patched") # To Dict return self.to_dict(instance) except SQLAlchemyError as ex: logging.exception(ex) self.send_error(status_code=400, exc_info=sys.exc_info()) finally: # Commit self.model.session.commit()
[docs] def delete(self, instance_id: str=None): """ DELETE (delete instance) request :param instance_id: query argument of request :type instance_id: comma seperated string list :statuscode 403: DELETE MANY disallowed :statuscode 405: DELETE disallowed """ if not 'delete' in self.methods: raise MethodNotAllowedError(self.request.method) # Call Preprocessor self._call_preprocessor(search_params=self.search_params) if instance_id is None: if self.allow_patch_many: result = self.delete_many() else: raise MethodNotAllowedError(self.request.method, status_code=403) else: result = self.delete_single(instance_id.split(",")) self._call_postprocessor(result=result) self.finish(result)
[docs] def delete_many(self) -> dict: """ Remove many instances :statuscode 200: instances successfull removed :query limit: limit the count of deleted instances :query single: If true sqlalchemy will raise an error if zero or more than one instances would be deleted """ # Flush self.model.session.flush() # Filters filters = self.get_filters() # Limit limit = self.get_query_argument("limit", None) # Call Preprocessor self._call_preprocessor(filters=filters) # Modify Instances if self.get_query_argument("single", False): instance = self.model.one(filters=filters) self.model.session.delete(instance) self.model.session.commit() num = 1 else: num = self.model.delete(limit=limit, filters=filters) # Commit self.model.session.commit() # Result self.set_status(200, "Removed") return {'num_removed': num}
[docs] def delete_single(self, instance_id: list) -> dict: """ Get one instance :param instance_id: query argument of request :type instance_id: list of primary keys :statuscode 204: instance successfull removed """ # Call Preprocessor self._call_preprocessor(instance_id=instance_id) # Get Instance instance = self.model.get(*instance_id) # Trigger deletion self.model.session.delete(instance) self.model.session.commit() # Status self.set_status(204, "Instance removed") return {}
[docs] def put(self, instance_id: str=None): """ PUT (update instance) request :param instance_id: query argument of request :type instance_id: comma seperated string list :statuscode 403: PUT MANY disallowed :statuscode 404: Error :statuscode 405: PUT disallowed """ if not 'put' in self.methods: raise MethodNotAllowedError(self.request.method) # Call Preprocessor self._call_preprocessor(search_params=self.search_params) if instance_id is None: if self.allow_patch_many: result = self.put_many() else: raise MethodNotAllowedError(self.request.method, status_code=403) else: result = self.put_single(instance_id.split(",")) self._call_postprocessor(result=result) self.finish(result)
put_many = patch_many put_single = patch_single
[docs] def post(self, instance_id: str=None): """ POST (new input) request :param instance_id: (ignored) :statuscode 204: instance successfull created :statuscode 404: Error :statuscode 405: POST disallowed """ if not 'post' in self.methods: raise MethodNotAllowedError(self.request.method) # Call Preprocessor self._call_preprocessor(search_params=self.search_params) result = self.post_single() self._call_postprocessor(result=result) self.finish(result)
def post_single(self): """ Post one instance """ try: values = self.get_argument_values() # Call Preprocessor self._call_preprocessor(data=values) # Create Instance instance = self.model(**values) # Flush self.model.session.commit() # Refresh self.model.session.refresh(instance) # Set Status self.set_status(201, "Created") # To Dict return self.to_dict(instance) except SQLAlchemyError: self.send_error(status_code=400, exc_info=sys.exc_info()) self.model.session.rollback() finally: # Commit self.model.session.commit() @memoized_instancemethod def get_content_encoding(self) -> str: """ Get the encoding the client sends us for encoding request.body correctly :reqheader Content-Type: Provide a charset in addition for decoding arguments. """ content_type_args = {k.strip(): v for k, v in parse_qs(self.request.headers['Content-Type']).items()} if 'charset' in content_type_args and content_type_args['charset']: return content_type_args['charset'][0] else: return 'latin1' @memoized_instancemethod def get_body_arguments(self) -> dict: """ Get arguments encode as json body :statuscode 415: Content-Type mismatch :reqheader Content-Type: application/x-www-form-urlencoded or application/json """ self.logger.debug(self.request.body) content_type = self.request.headers.get('Content-Type') if 'www-form-urlencoded' in content_type: payload = self.request.arguments for key, value in payload.items(): if len(value) == 0: payload[key] = None elif len(value) == 1: payload[key] = str(value[0], encoding=self.get_content_encoding()) else: payload[key] = [str(value, encoding=self.get_content_encoding()) for value in value] return payload elif 'application/json' in content_type: return loads(str(self.request.body, encoding=self.get_content_encoding())) else: raise HTTPError(415, content_type=content_type) def get_body_argument(self, name: str, default=RequestHandler._ARG_DEFAULT): """ Get an argument named key from json encoded body :param name: Name of argument :param default: Default value, if not provided HTTPError 404 is raised :return: :statuscode 404: Missing Argument """ arguments = self.get_body_arguments() if name in arguments: return arguments[name] elif default is RequestHandler._ARG_DEFAULT: raise HTTPError(400, "Missing argument %s" % name) else: return default @property def search_params(self) -> dict: """ The 'q' Dictionary """ try: return self._search_params except AttributeError: self._search_params = loads(self.get_argument("q", default="{}")) return self._search_params def get_query_argument(self, name: str, default=RequestHandler._ARG_DEFAULT): """ Get an argument named key from json encoded body :param name: :param default: :return: :raise: 400 Missing Argument :query q: The query argument """ if name in self.search_params: return self.search_params[name] elif default is RequestHandler._ARG_DEFAULT: raise HTTPError(400, "Missing argument %s" % name) else: return default def get_argument(self, name: str, *args, **kwargs): """ On PUT/PATCH many request parameter may be located in body instead of query :param name: Name of argument :param args: Additional position arguments @see tornado.web.RequestHandler.get_argument :param kwargs: Additional keyword arguments @see tornado.web.RequestHandler.get_argument """ try: return super().get_argument(name, *args, **kwargs) except HTTPError: if name == "q" and self.request.method in ['PUT', 'PATCH']: return self.get_body_argument(name, *args, **kwargs) else: raise def get_argument_values(self): """ Get all values provided via arguments :query q: (ignored) """ # Include Columns if self.include is not None: values = {k: self.get_body_argument(k) for k in self.include} else: values = {k: v for k, v in self.get_body_arguments().items()} # Exclude "q" if "q" in values: del values["q"] # Exclude Columns if self.exclude is not None: for column in list(self.exclude): if column in values: del values[column] # Silently Ignore proxies for proxy in self.model.proxies: if proxy.key in values: self.logger.debug("Skipping proxy: %s" % proxy.key) del values[proxy.key] # Silently Ignore hybrids for hybrid in self.model.hybrids: if hybrid.key in values: self.logger.debug("Skipping hybrid: %s" % hybrid.key) del values[hybrid.key] # Handle Relations extra values_relations = {} for relation_key, relation in self.model.relations.items(): if relation_key in values: values_relations[relation_key] = values[relation_key] del values[relation_key] # Check Columns #for column in values: # if not column in self.model.column_names: # raise IllegalArgumentError("Column '%s' not defined for model %s" % (column, self.model.model)) return values
[docs] def get(self, instance_id: str=None): """ GET request :param instance_id: query argument of request :type instance_id: comma seperated string list :statuscode 405: GET disallowed """ if not 'get' in self.methods: raise MethodNotAllowedError(self.request.method) # Call Preprocessor self._call_preprocessor(search_params=self.search_params) if instance_id is None: result = self.get_many() else: result = self.get_single(instance_id.split(",")) self._call_postprocessor(result=result) self.finish(result)
[docs] def get_single(self, instance_id: list) -> dict: """ Get one instance :param instance_id: query argument of request :type instance_id: list of primary keys """ # Call Preprocessor self._call_preprocessor(instance_id=instance_id) # Get Instance instance = self.model.get(*instance_id) # To Dict return self.to_dict(instance)
[docs] def get_many(self) -> dict: """ Get all instances Note that it is possible to provide offset and page as argument then it will return instances of the nth page and skip offset items :statuscode 400: if results_per_page > max_results_per_page or offset < 0 :query results_per_page: Overwrite the returned results_per_page :query offset: Skip offset instances :query page: Return nth page :query limit: limit the count of modified instances :query single: If true sqlalchemy will raise an error if zero or more than one instances would be deleted """ # All search params search_params = {'single': self.get_query_argument("single", False), 'results_per_page': int(self.get_argument("results_per_page", self.results_per_page)), 'offset': int(self.get_query_argument("offset", 0))} # Results per Page Check if search_params['results_per_page'] > self.max_results_per_page: raise IllegalArgumentError("request.results_per_page > application.max_results_per_page") # Offset & Page page = int(self.get_argument("page", '1')) - 1 search_params['offset'] += page * search_params['results_per_page'] if search_params['offset'] < 0: raise IllegalArgumentError("request.offset < 0") # Limit search_params['limit'] = self.get_query_argument("limit", search_params['results_per_page'] or None) # Filters filters = self.get_filters() # Call Preprocessor self._call_preprocessor(filters=filters, search_params=search_params) # Num Results num_results = self.model.count(filters=filters) if search_params['results_per_page']: total_pages = ceil(num_results / search_params['results_per_page']) else: total_pages = 1 # Get Instances if search_params['single']: instances = [self.model.one(offset=search_params['offset'], filters=filters)] else: instances = self.model.all(offset=search_params['offset'], limit=search_params['limit'], filters=filters) return {'num_results': num_results, "total_pages": total_pages, "page": page + 1, "objects": self.to_dict(instances)}
def _call_preprocessor(self, *args, **kwargs): """ Calls a preprocessor with args and kwargs """ func_name = inspect.stack()[1][3] if func_name in self.preprocessor: for func in self.preprocessor[func_name]: func(*args, model=self.model, handler=self, **kwargs) def _call_postprocessor(self, *args, **kwargs): """ Calls a postprocessor with args and kwargs """ func_name = inspect.stack()[1][3] if func_name in self.postprocessor: for func in self.postprocessor[func_name]: func(*args, model=self.model, handler=self, **kwargs) @memoized_property
[docs] def logger(self): """ Tornado Restless Logger """ return logging.getLogger('tornado.restless')
def to_dict(self, instance): """ Wrapper to convert.to_dict with arguments from blueprint init :param instance: Instance to be translated """ return to_dict(instance, include=self.include, exclude=self.exclude, options=self.to_dict_options)