diff --git a/CHANGES b/CHANGES index 4805eb68..66ad1743 100644 --- a/CHANGES +++ b/CHANGES @@ -28,6 +28,9 @@ Not yet released. - #449: roll back the session on any SQLAlchemy error, not just a few. - #436, #453: use ``__table__.name`` instead of ``__tablename__`` to infer the collection name for the SQLAlchemy model. +- #440, #475: uses the serialization function provided at the time of invoking + :meth:`APIManager.create_api` to serialize each resource correctly, depending + on its type. - #474: include license files in built wheel for distribution. Older versions diff --git a/docs/api.rst b/docs/api.rst index 7b1dba92..c1ad043a 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -18,6 +18,8 @@ in Flask-Restless. .. autofunction:: model_for(collection_name, _apimanager=None) +.. autofunction:: serializer_for(model, _apimanager=None) + .. autofunction:: url_for(model, instid=None, relationname=None, relationinstid=None, _apimanager=None, **kw) .. autoclass:: ProcessingException diff --git a/docs/customizing.rst b/docs/customizing.rst index 225dcbb4..666262cf 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -195,11 +195,34 @@ follows:: For a complete version of this example, see the :file:`examples/server_configurations/custom_serialization.py` module in the -source distribution, or `view it online`_ +source distribution, or `view it online`_. .. _Marshmallow: https://marshmallow.readthedocs.org .. _view it online: https://github.com/jfinkels/flask-restless/tree/master/examples/server_configurations/custom_serialization.py +Per-model serialization +----------------------- + +The correct serialization function will be used for each type of SQLAlchemy +model for which you invoke :meth:`APIManager.create_api`. For example, if you +create two APIs, one for ``Person`` objects and one for ``Article`` objects, :: + + manager.create_api(Person, serializer=person_serializer) + manager.create_api(Article, serializer=article_serializer) + +and then make a request like + +.. sourcecode:: http + + GET /api/article/1?include=author HTTP/1.1 + Host: example.com + Accept: application/vnd.api+json + +then Flask-Restless will use the ``article_serializer`` function to serialize +the primary data (that is, the top-level ``data`` element in the response +document) and the ``person_serializer`` to serialize the included ``Person`` +resource. + .. _validation: Capturing validation errors diff --git a/flask_restless/__init__.py b/flask_restless/__init__.py index 64d9cec9..e4202bfc 100644 --- a/flask_restless/__init__.py +++ b/flask_restless/__init__.py @@ -24,6 +24,7 @@ # ``from flask.ext.restless import APIManager``, for example. from .helpers import collection_name from .helpers import model_for +from .helpers import serializer_for from .helpers import url_for from .manager import APIManager from .manager import IllegalArgumentError diff --git a/flask_restless/helpers.py b/flask_restless/helpers.py index 86de09d0..79a8da45 100644 --- a/flask_restless/helpers.py +++ b/flask_restless/helpers.py @@ -447,6 +447,27 @@ def __call__(self, model, resource_id=None, relation_name=None, raise ValueError(message) +class SerializerFinder(KnowsAPIManagers, Singleton): + """The singleton class that backs the :func:`serializer_for` function.""" + + def __call__(self, model, _apimanager=None, **kw): + if _apimanager is not None: + if model not in _apimanager.created_apis_for: + message = ('APIManager {0} has not created an API for model ' + ' {1}').format(_apimanager, model) + raise ValueError(message) + return _apimanager.serializer_for(model, **kw) + for manager in self.created_managers: + try: + return self(model, _apimanager=manager, **kw) + except ValueError: + pass + message = ('Model {0} is not known to any APIManager' + ' objects; maybe you have not called' + ' APIManager.create_api() for this model.').format(model) + raise ValueError(message) + + #: Returns the URL for the specified model, similar to :func:`flask.url_for`. #: #: `model` is a SQLAlchemy model class. This should be a model on which @@ -458,8 +479,8 @@ def __call__(self, model, resource_id=None, relation_name=None, #: :class:`APIManager`. Restrict our search for endpoints exposing `model` to #: only endpoints created by the specified :class:`APIManager` instance. #: -#: `resource_id`, `relation_name`, and `relationresource_id` allow you to get a more -#: specific sub-resource. +#: The `resource_id`, `relation_name`, and `relationresource_id` keyword +#: arguments allow you to get the URL for a more specific sub-resource. #: #: For example, suppose you have a model class ``Person`` and have created the #: appropriate Flask application and SQLAlchemy session:: @@ -521,6 +542,36 @@ def __call__(self, model, resource_id=None, relation_name=None, #: collection_name = CollectionNameFinder() +#: Returns the callable serializer object for the specified model, as +#: specified by the `serializer` keyword argument to +#: :meth:`APIManager.create_api` when it was previously invoked on the +#: model. +#: +#: `model` is a SQLAlchemy model class. This should be a model on which +#: :meth:`APIManager.create_api_blueprint` (or :meth:`APIManager.create_api`) +#: has been invoked previously. If no API has been created for it, this +#: function raises a `ValueError`. +#: +#: If `_apimanager` is not ``None``, it must be an instance of +#: :class:`APIManager`. Restrict our search for endpoints exposing +#: `model` to only endpoints created by the specified +#: :class:`APIManager` instance. +#: +#: For example, suppose you have a model class ``Person`` and have +#: created the appropriate Flask application and SQLAlchemy session:: +#: +#: >>> from mymodels import Person +#: >>> def my_serializer(model, *args, **kw): +#: ... # return something cool here... +#: ... return {} +#: ... +#: >>> manager = APIManager(app, session=session) +#: >>> manager.create_api(Person, serializer=my_serializer) +#: >>> serializer_for(Person) +#: +#: +serializer_for = SerializerFinder() + #: Returns the model corresponding to the given collection name, as specified #: by the ``collection_name`` keyword argument to :meth:`APIManager.create_api` #: when it was previously invoked on the model. diff --git a/flask_restless/manager.py b/flask_restless/manager.py index 6de02c1c..6996adff 100644 --- a/flask_restless/manager.py +++ b/flask_restless/manager.py @@ -30,6 +30,7 @@ from flask import Blueprint from .helpers import collection_name +from .helpers import serializer_for from .helpers import model_for from .helpers import url_for from .serialization import DefaultSerializer @@ -57,16 +58,21 @@ #: #: These tuples are used by :class:`APIManager` to store information about #: Flask applications registered using :meth:`APIManager.init_app`. -RestlessInfo = namedtuple('RestlessInfo', ['session', - 'universal_preprocessors', - 'universal_postprocessors']) +# RestlessInfo = namedtuple('RestlessInfo', ['session', +# 'universal_preprocessors', +# 'universal_postprocessors']) #: A tuple that stores information about a created API. #: -#: The first element, `collection_name`, is the name by which a collection of -#: instances of the model which this API exposes is known. The second element, -#: `blueprint_name`, is the name of the blueprint that contains this API. -APIInfo = namedtuple('APIInfo', 'collection_name blueprint_name') +#: The elements are, in order, +#: +#: - `collection_name`, the name by which a collection of instances of +#: the model exposed by this API is known, +#: - `blueprint_name`, the name of the blueprint that contains this API, +#: - `serializer`, the subclass of :class:`Serializer` provided for the +#: model exposed by this API. +#: +APIInfo = namedtuple('APIInfo', 'collection_name blueprint_name serializer') class IllegalArgumentError(Exception): @@ -163,6 +169,7 @@ def __init__(self, app=None, session=None, flask_sqlalchemy_db=None, url_for.register(self) model_for.register(self) collection_name.register(self) + serializer_for.register(self) #: A mapping whose keys are models for which this object has #: created an API via the :meth:`create_api_blueprint` method @@ -298,7 +305,23 @@ def collection_name(self, model): """ return self.created_apis_for[model].collection_name + def serializer_for(self, model): + """Returns the serializer for the specified model, as specified + by the `serializer` keyword argument to + :meth:`create_api_blueprint`. + + `model` is a SQLAlchemy model class. This must be a model on + which :meth:`create_api_blueprint` has been invoked previously, + otherwise a :exc:`KeyError` is raised. + + This method only returns URLs for endpoints created by this + :class:`APIManager`. + + """ + return self.created_apis_for[model].serializer + def init_app(self, app): + """Registers any created APIs on the given Flask application. This function should only be called if no Flask application was @@ -734,7 +757,8 @@ def create_api_blueprint(self, name, model, methods=READONLY_METHODS, # Finally, record that this APIManager instance has created an API for # the specified model. - self.created_apis_for[model] = APIInfo(collection_name, blueprint.name) + self.created_apis_for[model] = APIInfo(collection_name, blueprint.name, + serializer) return blueprint def create_api(self, *args, **kw): diff --git a/flask_restless/serialization.py b/flask_restless/serialization.py index af292901..09174868 100644 --- a/flask_restless/serialization.py +++ b/flask_restless/serialization.py @@ -50,6 +50,7 @@ from .helpers import is_like_list from .helpers import primary_key_name from .helpers import primary_key_value +from .helpers import serializer_for from .helpers import strings_to_datetimes from .helpers import url_for @@ -73,17 +74,24 @@ class SerializationException(Exception): """Raised when there is a problem serializing an instance of a SQLAlchemy model to a dictionary representation. - ``instance`` is the (problematic) instance on which + `instance` is the (problematic) instance on which :meth:`Serializer.__call__` was invoked. + `message` is an optional string describing the problem in more + detail. + `resource` is an optional partially-constructed serialized representation of ``instance``. + Each of these keyword arguments is stored in a corresponding + instance attribute so client code can access them. + """ - def __init__(self, instance, resource=None, *args, **kw): + def __init__(self, instance, message=None, resource=None, *args, **kw): super(SerializationException, self).__init__(*args, **kw) self.resource = resource + self.message = message self.instance = instance @@ -533,12 +541,20 @@ def __call__(self, instance, only=None): # attributes. This may happen if, for example, the return value # of one of the callable functions is an instance of another # SQLAlchemy model class. - for k, v in attributes.items(): + for key, val in attributes.items(): # This is a bit of a fragile test for whether the object # needs to be serialized: we simply check if the class of # the object is a mapped class. - if is_mapped_class(type(v)): - attributes[k] = simple_serialize(v) + if is_mapped_class(type(val)): + model_ = get_model(val) + try: + serialize = serializer_for(model_) + except ValueError: + # TODO Should this cause an exception, or fail + # silently? See similar comments in `views/base.py`. + # # raise SerializationException(instance) + serialize = simple_serialize + attributes[key] = serialize(val) # Get the ID and type of the resource. id_ = attributes.pop('id') type_ = collection_name(model) @@ -575,15 +591,6 @@ def __call__(self, instance, only=None): # value = value() # result[method] = value - # Recursively serialize values that are themselves SQLAlchemy - # models. - # - # TODO We really need to serialize each model using the - # serializer defined for that class when the user called - # APIManager.create_api - for key, value in result.items(): - if key not in column_attrs and is_mapped_class(type(value)): - result[key] = simple_serialize(value) # If the primary key is not named "id", we'll duplicate the # primary key under the "id" key. pk_name = primary_key_name(model) diff --git a/flask_restless/views/base.py b/flask_restless/views/base.py index 7670ff0b..0eabef92 100644 --- a/flask_restless/views/base.py +++ b/flask_restless/views/base.py @@ -53,6 +53,7 @@ from ..helpers import is_like_list from ..helpers import primary_key_name from ..helpers import primary_key_value +from ..helpers import serializer_for from ..helpers import url_for from ..search import ComparisonToNull from ..search import search @@ -734,31 +735,47 @@ def errors_response(status, errors): return {'errors': errors, _STATUS: status}, status -def error_from_serialization_exception(exception): +def error_from_serialization_exception(exception, included=False): """Returns an error dictionary, as returned by :func:`error`, representing the given instance of :exc:`SerializationException`. The ``detail`` element in the returned dictionary will be more detailed if :attr:`SerializationException.instance` is not ``None``. + If `included` is ``True``, this indicates that the exceptions were + raised by attempts to serialize resources included in a compound + document; this modifies the error message for the exceptions a bit + to indicate that the resources were included resource, not primary + data. If :attr:`~SerializationException.instance` is not ``None``, + however, that message is preferred and `included` has no effect. + """ # As long as `exception` is a `SerializationException` that has been # initialized with an actual instance of a SQLAlchemy model, these # helper function calls should not cause a problem. type_ = collection_name(get_model(exception.instance)) id_ = primary_key_value(exception.instance) - detail = 'Failed to serialize resource of type {0} and ID {1}' - detail = detail.format(type_, id_) + if exception.message is not None: + detail = exception.message + else: + resource = 'included resource' if included else 'resource' + detail = 'Failed to serialize {0} of type {1} and ID {2}' + detail = detail.format(resource, type_, id_) return error(status=500, detail=detail) -def errors_from_serialization_exceptions(exceptions): +def errors_from_serialization_exceptions(exceptions, included=False): """Returns an errors response object, as returned by :func:`errors_response`, representing the given list of :exc:`SerializationException` objects. + If `included` is ``True``, this indicates that the exceptions were + raised by attempts to serialize resources included in a compound + document; this modifies the error message for the exceptions a bit. + """ - errors = [error_from_serialization_exception(e) for e in exceptions] + _to_error = partial(error_from_serialization_exception, included=included) + errors = list(map(_to_error, exceptions)) return errors_response(500, errors) @@ -1114,15 +1131,6 @@ def __init__(self, session, model, preprocessors=None, postprocessors=None, #: A custom serialization function for linkage objects. self.serialize_relationship = simple_relationship_serialize - # :attr:`primary_serializer` serializes linkage objects if - # :meth:`use_resource_identifiers` indicates that the instance - # of this class fetches relationship objects. Otherwise it - # serializes resource objects. - if self.use_resource_identifiers(): - self.primary_serializer = self.serialize_relationship - else: - self.primary_serializer = self.serialize - #: A custom deserialization function for primary resources; see #: :ref:`serialization` for more information. #: @@ -1145,6 +1153,10 @@ def __init__(self, session, model, preprocessors=None, postprocessors=None, #: the main functionality of that method has been executed. self.preprocessors = defaultdict(list, upper(preprocessors or {})) + #: The mapping from resource type name to requested sparse + #: fields for resources of that type. + self.sparse_fields = parse_sparse_fields() + # HACK: We would like to use the :attr:`API.decorators` class attribute # in order to decorate each view method with a decorator that catches # database integrity errors. However, in order to rollback the session, @@ -1208,7 +1220,57 @@ def _handle_validation_exception(self, exception): current_app.logger.exception(str(exception)) return errors_response(400, errors) - def get_all_inclusions(self, instance_or_instances, fields): + def _serialize_many(self, instances, relationship=False): + """Serializes a list of SQLAlchemy objects. + + `instances` is a list of SQLAlchemy objects of any model class. + + This function returns a list of dictionary objects, each of + which is the serialized version of the corresponding SQLAlchemy + model instance from `instances`. + + If `relationship` is ``True``, resource identifier objects will + be returned instead of resource objects. + + This function raises :exc:`MultipleExceptions` if there is a + problem serializing one or more of the objects in `instances`. + + """ + result = [] + failed = [] + for instance in instances: + model = get_model(instance) + if relationship: + serialize = self.serialize_relationship + else: + # Determine the serializer for this instance. If there + # is no serializer, use the default serializer for the + # current resource, even though the current model may + # different from the model of the current instance. + try: + serialize = serializer_for(model) + except ValueError: + # TODO Should we fail instead, thereby effectively + # requiring that an API has been created for each + # type of resource? This is mainly a design + # question. + serialize = self.serialize + # This may raise ValueError + _type = collection_name(model) + # TODO The `only` keyword argument will be ignored when + # serializing relationships, so we don't really need to + # recompute this every time. + only = self.sparse_fields.get(_type) + try: + serialized = serialize(instance, only=only) + result.append(serialized) + except SerializationException as exception: + failed.append(exception) + if failed: + raise MultipleExceptions(failed) + return result + + def get_all_inclusions(self, instance_or_instances): """Returns a list of all the requested included resources associated with the given instance or instances of a SQLAlchemy model. @@ -1221,9 +1283,6 @@ def get_all_inclusions(self, instance_or_instances, fields): response. The resources to include will be computed based on these data and the client's ``include`` query parameter. - ``fields`` is the dictionary of fields to include keyed by - resource type, as returned by :func:`parse_sparse_fields`. - This function raises :exc:`MultipleExceptions` if any included resource causes a serialization exception. If this exception is raised, the :attr:`MultipleExceptions.exceptions` attribute @@ -1231,31 +1290,18 @@ def get_all_inclusions(self, instance_or_instances, fields): that caused it. """ - # If `instances` is actually just a single instance of a - # SQLAlchemy model, get the resources to include for that one - # instance. Otherwise, collect the resources to include for each - # instance in `instances`. + # If `instance_or_instances` is actually just a single instance + # of a SQLAlchemy model, get the resources to include for that + # one instance. Otherwise, collect the resources to include for + # each instance in `instances`. if isinstance(instance_or_instances, Query): to_include = set(chain(self.resources_to_include(resource) for resource in instance_or_instances)) else: to_include = self.resources_to_include(instance_or_instances) - # Serialize each instance to include. Collect those that fail - # serialization and raise an exception representing the multiple - # serialization errors. - result = [] - failed = [] - for instance in to_include: - _type = collection_name(get_model(instance)) - only = fields.get(_type) - try: - serialized = self.serialize(instance, only=only) - result.append(serialized) - except SerializationException as exception: - failed.append(exception) - if failed: - raise MultipleExceptions(failed) - return result + # This may raise MultipleExceptions if there are problems + # serializing the included resources. + return self._serialize_many(to_include) def _collection_parameters(self): """Gets filtering, sorting, grouping, and other settings from the @@ -1319,8 +1365,7 @@ def _collection_parameters(self): return filters, sort, group_by, single - def _paginated(self, items, filters=None, sort=None, group_by=None, - only=None): + def _paginated(self, items, filters=None, sort=None, group_by=None): """Returns a :class:`Paginated` object representing the correctly paginated list of resources to return to the client, based on the current request. @@ -1333,16 +1378,13 @@ def _paginated(self, items, filters=None, sort=None, group_by=None, extracted from the client's request (as by :meth:`_collection_parameters`) and applied to the query. - `only` must have already been parsed from the request (as by - :func:`parse_sparse_fields`). - If `relationship` is ``True``, the resources in the query object will be serialized as linkage objects instead of resources objects. This method serializes the (correct page of) resources. As such, - it may raise :exc:`SerializationException` if there is a problem - serializing any of the resources. + it raises an instance of :exc:`MultipleExceptions` if there is a + problem serializing resources. """ # Determine the client's page size request. Raise an exception @@ -1355,15 +1397,14 @@ def _paginated(self, items, filters=None, sort=None, group_by=None, msg = "Page size must not exceed the server's maximum: {0}" msg = msg.format(self.max_page_size) raise PaginationError(msg) + is_relationship = self.use_resource_identifiers() # If the page size is 0, just return everything. if page_size == 0: - # TODO This can be parallelized. - items = [self.primary_serializer(instance, only=only) - for instance in items] + result = self._serialize_many(items, relationship=is_relationship) # Use `len()` here instead of doing `count(self.session, # items)` because the former should be faster. - num_results = len(items) - return Paginated(items, page_size=page_size, + num_results = len(result) + return Paginated(result, page_size=page_size, num_results=num_results) # Determine the client's page number request. Raise an exception # if the page number is out of bounds. @@ -1402,31 +1443,36 @@ def _paginated(self, items, filters=None, sort=None, group_by=None, offset = (page_number - 1) * page_size # TODO Use Query.slice() instead, since it's easier to use. items = items.limit(page_size).offset(offset) - items = [self.primary_serializer(instance, only=only) - for instance in items] - return Paginated(items, num_results=num_results, first=first, + # Serialize the found items. This may raise an exception if + # there is a problem serializing any of the objects. + result = self._serialize_many(items, relationship=is_relationship) + # Wrap the list of results in a Paginated object, which + # represents the result set and stores some extra information + # about how it was determined. + return Paginated(result, num_results=num_results, first=first, last=last, next_=next_, prev=prev, page_size=page_size, filters=filters, sort=sort, group_by=group_by) def _get_resource_helper(self, resource, primary_resource=None, relation_name=None, related_resource=False): - # Determine the fields to include for each type of object. - fields = parse_sparse_fields() - + is_relationship = self.use_resource_identifiers() # The resource to serialize may be `None`, if we are fetching a # to-one relation that has no value. In this case, the "data" # for the JSON API response is just `None`. if resource is None: data = None else: - type_ = self.collection_name - only = fields.get(type_) - # Serialize the resource. + # HACK The _serialize_many() method expects a list as input and + # returns a list as output, but we only need to serialize a + # single resource here. Thus we provide a list of length one + # as input and assume a list of length one as output. try: - data = self.primary_serializer(resource, only=only) - except SerializationException as exception: - return errors_from_serialization_exceptions([exception]) + data = self._serialize_many([resource], + relationship=is_relationship) + except MultipleExceptions as e: + return errors_from_serialization_exceptions(e.exceptions) + data = data[0] # Prepare the dictionary that will contain the JSON API response. result = {'jsonapi': {'version': JSONAPI_VERSION}, 'meta': {}, 'links': {}, 'data': data} @@ -1443,7 +1489,7 @@ def _get_resource_helper(self, resource, primary_resource=None, elif is_relation: resource_id = primary_key_value(primary_resource) # `self.model` should match `get_model(primary_resource)` - if self.use_resource_identifiers(): + if is_relationship: self_link = url_for(self.model, resource_id, relation_name, relationship=True) related_link = url_for(self.model, resource_id, relation_name) @@ -1457,21 +1503,23 @@ def _get_resource_helper(self, resource, primary_resource=None, # Include any requested resources in a compound document. try: - included = self.get_all_inclusions(resource, fields) + included = self.get_all_inclusions(resource) except MultipleExceptions as e: # By the way we defined `get_all_inclusions()`, we are # guaranteed that each of the underlying exceptions is a # `SerializationException`. Thus we can use # `errors_from_serialization_exception()`. - return errors_from_serialization_exceptions(e.exceptions) + return errors_from_serialization_exceptions(e.exceptions, + included=True) if included: result['included'] = included + # HACK Need to do this here to avoid a too-long line. + kw = {'is_relation': is_relation, + 'is_related_resource': is_related_resource} # This method could have been called on a request to fetch a # single resource, a to-one relation, or a member of a to-many # relation. - processor_type = self.resource_processor_type(is_relation=is_relation, - is_related_resource=is_related_resource) - processor_type = 'GET_{0}'.format(processor_type) + processor_type = 'GET_{0}'.format(self.resource_processor_type(**kw)) for postprocessor in self.postprocessors[processor_type]: postprocessor(result=result) return result, 200 @@ -1503,11 +1551,6 @@ def _get_collection_helper(self, resource=None, relation_name=None, detail = 'Unable to construct query' return error_response(400, cause=exception, detail=detail) - # Determine the client's request for which fields to include for this - # type of object. - fields = parse_sparse_fields() - only = fields.get(self.collection_name) - # Prepare the dictionary that will contain the JSON API response. result = {'links': {'self': url_for(self.model)}, 'jsonapi': {'version': JSONAPI_VERSION}, @@ -1521,10 +1564,9 @@ def _get_collection_helper(self, resource=None, relation_name=None, if not single: try: paginated = self._paginated(search_items, filters=filters, - sort=sort, group_by=group_by, - only=only) - except SerializationException as exception: - return errors_from_serialization_exceptions([exception]) + sort=sort, group_by=group_by) + except MultipleExceptions as e: + return errors_from_serialization_exceptions(e.exceptions) except PaginationError as exception: detail = exception.args[0] return error_response(400, cause=exception, detail=detail) @@ -1545,9 +1587,14 @@ def _get_collection_helper(self, resource=None, relation_name=None, except MultipleResultsFound as exception: detail = 'Multiple results found' return error_response(404, cause=exception, detail=detail) + only = self.sparse_fields.get(self.collection_name) # Wrap the resulting resource under a `data` key. try: - result['data'] = self.primary_serializer(data, only=only) + if self.use_resource_identifiers(): + serialize = self.serialize_relationship + else: + serialize = self.serialize + result['data'] = serialize(data, only=only) except SerializationException as exception: return errors_from_serialization_exceptions([exception]) primary_key = self.primary_key or primary_key_name(data) @@ -1565,13 +1612,14 @@ def _get_collection_helper(self, resource=None, relation_name=None, instances = search_items # Include any requested resources in a compound document. try: - included = self.get_all_inclusions(instances, fields) + included = self.get_all_inclusions(instances) except MultipleExceptions as e: # By the way we defined `get_all_inclusions()`, we are # guaranteed that each of the underlying exceptions is a # `SerializationException`. Thus we can use # `errors_from_serialization_exception()`. - return errors_from_serialization_exceptions(e.exceptions) + return errors_from_serialization_exceptions(e.exceptions, + included=True) if included: result['included'] = included diff --git a/flask_restless/views/resources.py b/flask_restless/views/resources.py index 25b236bd..8e57d4d7 100644 --- a/flask_restless/views/resources.py +++ b/flask_restless/views/resources.py @@ -27,7 +27,6 @@ from ..helpers import is_like_list from ..helpers import primary_key_value from ..helpers import strings_to_datetimes -from ..serialization import ConflictingType from ..serialization import DeserializationException from ..serialization import SerializationException from .base import APIBase @@ -37,7 +36,6 @@ from .base import errors_response from .base import JSONAPI_VERSION from .base import MultipleExceptions -from .base import parse_sparse_fields from .base import SingleKeyError from .helpers import changes_on_update @@ -415,8 +413,7 @@ def post(self): return error_response(400, cause=exception, detail=detail) except self.validation_exceptions as exception: return self._handle_validation_exception(exception) - fields = parse_sparse_fields() - fields_for_this = fields.get(self.collection_name) + fields_for_this = self.sparse_fields.get(self.collection_name) # Get the dictionary representation of the new instance as it # appears in the database. try: @@ -436,13 +433,14 @@ def post(self): result = {'jsonapi': {'version': JSONAPI_VERSION}, 'data': data} # Include any requested resources in a compound document. try: - included = self.get_all_inclusions(instance, fields) + included = self.get_all_inclusions(instance) except MultipleExceptions as e: # By the way we defined `get_all_inclusions()`, we are # guaranteed that each of the underlying exceptions is a # `SerializationException`. Thus we can use # `errors_from_serialization_exception()`. - return errors_from_serialization_exceptions(e.exceptions) + return errors_from_serialization_exceptions(e.exceptions, + included=True) if included: result['included'] = included status = 201 diff --git a/tests/test_creating.py b/tests/test_creating.py index 43e0ba62..0d4c622f 100644 --- a/tests/test_creating.py +++ b/tests/test_creating.py @@ -61,6 +61,27 @@ from .helpers import unregister_fsa_session_signals +def raise_s_exception(instance, *args, **kw): + """Immediately raises a :exc:`SerializationException` with access to + the provided `instance` of a SQLAlchemy model. + + This function is useful for use in tests for serialization + exceptions. + + """ + raise SerializationException(instance) + + +def raise_d_exception(*args, **kw): + """Immediately raises a :exc:`DeserializationException`. + + This function is useful for use in tests for deserialization + exceptions. + + """ + raise DeserializationException() + + class TestCreating(ManagerTestBase): """Tests for creating resources.""" @@ -567,18 +588,42 @@ def deserializer(data, *args, **kw): person = document['data'] assert person['attributes']['foo'] == 'bar' + def test_serialization_exception_included(self): + """Tests that exceptions are caught when trying to serialize + included resources. + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + self.manager.create_api(self.Article, methods=['POST'], + url_prefix='/api2') + self.manager.create_api(self.Person, serializer=raise_s_exception) + data = {'data': + {'type': 'article', + 'relationships': + {'author': + {'data': + {'type': 'person', 'id': 1} + } + } + } + } + query_string = {'include': 'author'} + response = self.app.post('/api/article', data=dumps(data), + query_string=query_string) + check_sole_error(response, 500, ['Failed to serialize', + 'included resource', 'type', 'person', + 'ID', '1']) + def test_deserialization_exception(self): """Tests that exceptions are caught when a custom deserialization method raises an exception. """ - - def deserializer(*args, **kw): - raise DeserializationException - self.manager.create_api(self.Person, methods=['POST'], url_prefix='/api2', - deserializer=deserializer) + deserializer=raise_d_exception) data = dict(data=dict(type='person')) response = self.app.post('/api2/person', data=dumps(data)) assert response.status_code == 400 @@ -589,12 +634,9 @@ def test_serialization_exception(self): raises an exception. """ - - def serializer(instance, *args, **kw): - raise SerializationException(instance) - self.manager.create_api(self.Person, methods=['POST'], - url_prefix='/api2', serializer=serializer) + url_prefix='/api2', + serializer=raise_s_exception) data = dict(data=dict(type='person')) response = self.app.post('/api2/person', data=dumps(data)) assert response.status_code == 400 diff --git a/tests/test_fetching.py b/tests/test_fetching.py index 2777d80a..b1e3c971 100644 --- a/tests/test_fetching.py +++ b/tests/test_fetching.py @@ -18,10 +18,7 @@ specification. """ -from datetime import datetime -from datetime import time from operator import itemgetter -from uuid import uuid1 try: from flask.ext.sqlalchemy import SQLAlchemy @@ -30,13 +27,9 @@ else: has_flask_sqlalchemy = True from sqlalchemy import Column -from sqlalchemy import Date -from sqlalchemy import DateTime from sqlalchemy import ForeignKey from sqlalchemy import Integer -from sqlalchemy import Time from sqlalchemy import Unicode -from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.orm import backref from sqlalchemy.orm import relationship @@ -44,12 +37,10 @@ from flask.ext.restless import APIManager from flask.ext.restless import ProcessingException from flask.ext.restless import simple_serialize -from flask.ext.restless import SerializationException from .helpers import check_sole_error from .helpers import dumps from .helpers import FlaskTestBase -from .helpers import GUID from .helpers import loads from .helpers import MSIE8_UA from .helpers import MSIE9_UA @@ -210,28 +201,6 @@ def test_group_by_related(self): for article in articles) assert ['1', '2'] == author_ids - def test_serialization_exception_single(self): - """Tests for a serialization exception on a filtered single - response. - - """ - person = self.Person(id=1) - self.session.add(person) - self.session.commit() - - def serializer(instance, *args, **kw): - raise SerializationException(instance) - - self.manager.create_api(self.Person, serializer=serializer, - url_prefix='/api2') - query_string = {'filter[single]': 1} - response = self.app.get('/api2/person', query_string=query_string) - assert response.status_code == 500 - document = loads(response.data) - errors = document['errors'] - error = errors[0] - assert 'serialize' in error['detail'] - def test_pagination_links_empty_collection(self): """Tests that pagination links work correctly for an empty collection. @@ -258,8 +227,6 @@ def test_link_headers_empty_collection(self): base_url = '/api/person' response = self.app.get(base_url) assert response.status_code == 200 - document = loads(response.data) - pagination = document['links'] base_url = '{0}?'.format(base_url) # There should be exactly two, one for the first page and one # for the last page; there are no previous or next pages, so @@ -325,106 +292,31 @@ class TestFetchResource(ManagerTestBase): def setup(self): super(TestFetchResource, self).setup() - class Article(self.Base): - __tablename__ = 'article' - id = Column(Integer, primary_key=True) - title = Column(Unicode, primary_key=True) - author_id = Column(Integer, ForeignKey('person.id')) - author = relationship('Person', backref=backref('articles')) + # class Article(self.Base): + # __tablename__ = 'article' + # id = Column(Integer, primary_key=True) + # title = Column(Unicode, primary_key=True) class Person(self.Base): __tablename__ = 'person' id = Column(Integer, primary_key=True) - uuid = Column(GUID) - name = Column(Unicode) - bedtime = Column(Time) - birth_datetime = Column(DateTime) - birthday = Column(Date) - - @hybrid_property - def has_early_bedtime(self): - if hasattr(self, 'bedtime'): - if self.bedtime is None: - return False - nine_oclock = time(21) - return self.bedtime < nine_oclock - return False - class Comment(self.Base): - __tablename__ = 'comment' - id = Column(Integer, primary_key=True) - author_id = Column(Integer, ForeignKey('person.id')) - author = relationship('Person', backref=backref('comments')) - article_id = Column(Integer, ForeignKey('article.id')) - article = relationship('Article', backref=backref('comments')) - - class Tag(self.Base): - __tablename__ = 'tag' - name = Column(Unicode, primary_key=True) + # class Tag(self.Base): + # __tablename__ = 'tag' + # name = Column(Unicode, primary_key=True) - self.Article = Article - self.Comment = Comment + # self.Article = Article self.Person = Person - self.Tag = Tag + # self.Tag = Tag self.Base.metadata.create_all() - self.manager.create_api(Article) - self.manager.create_api(Comment) + # self.manager.create_api(Article) self.manager.create_api(Person) # self.manager.create_api(Tag) - def test_serialize_uuid(self): - """Tests for serializing a (non-primary key) UUID field.""" - uuid = uuid1() - person = self.Person(id=1, uuid=uuid) - self.session.add(person) - self.session.commit() - response = self.app.get('/api/person/1') - assert response.status_code == 200 - document = loads(response.data) - person = document['data'] - assert person['attributes']['uuid'] == str(uuid) - - def test_serialize_time(self): - """Test for getting the JSON representation of a time field.""" - now = datetime.now().time() - person = self.Person(id=1, bedtime=now) - self.session.add(person) - self.session.commit() - response = self.app.get('/api/person/1') - assert response.status_code == 200 - document = loads(response.data) - person = document['data'] - assert person['attributes']['bedtime'] == now.isoformat() - - def test_serialize_datetime(self): - """Test for getting the JSON representation of a datetime field.""" - now = datetime.now() - person = self.Person(id=1, birth_datetime=now) - self.session.add(person) - self.session.commit() - response = self.app.get('/api/person/1') - assert response.status_code == 200 - document = loads(response.data) - person = document['data'] - assert person['attributes']['birth_datetime'] == now.isoformat() - - def test_serialize_date(self): - """Test for getting the JSON representation of a date field.""" - now = datetime.now().date() - person = self.Person(id=1, birthday=now) - self.session.add(person) - self.session.commit() - response = self.app.get('/api/person/1') - assert response.status_code == 200 - document = loads(response.data) - person = document['data'] - assert person['attributes']['birthday'] == now.isoformat() - def test_jsonp(self): """Test for a JSON-P callback on a single resource request.""" - person1 = self.Person(id=1) - person2 = self.Person(id=2) - self.session.add_all([person1, person2]) + person = self.Person(id=1) + self.session.add(person) self.session.commit() response = self.app.get('/api/person/1?callback=foo') assert response.data.startswith(b'foo(') @@ -483,21 +375,6 @@ def test_specified_primary_key(self): assert resource['id'] == str(article.id) assert resource['title'] == article.title - def test_hybrid_property(self): - """Tests for fetching a resource with a hybrid property attribute.""" - person1 = self.Person(id=1, bedtime=time(20)) - person2 = self.Person(id=2, bedtime=time(22)) - self.session.add_all([person1, person2]) - self.session.commit() - response = self.app.get('/api/person/1') - document = loads(response.data) - person = document['data'] - assert person['attributes']['has_early_bedtime'] - response = self.app.get('/api/person/2') - document = loads(response.data) - person = document['data'] - assert not person['attributes']['has_early_bedtime'] - def test_collection_name(self): """Tests for fetching a single resource with an alternate collection name. @@ -506,109 +383,15 @@ def test_collection_name(self): person = self.Person(id=1) self.session.add(person) self.session.commit() - self.manager.create_api(self.Person, collection_name='people') - response = self.app.get('/api/people/1') + self.manager.create_api(self.Person, collection_name='people', + url_prefix='/api2') + response = self.app.get('/api2/people/1') assert response.status_code == 200 document = loads(response.data) person = document['data'] assert person['id'] == '1' assert person['type'] == 'people' - def test_custom_serialization(self): - """Tests for a custom serialization function.""" - person = self.Person(id=1) - self.session.add(person) - self.session.commit() - - def serializer(instance, **kw): - result = simple_serialize(instance, **kw) - result['attributes']['foo'] = 'bar' - return result - - self.manager.create_api(self.Person, serializer=serializer, - url_prefix='/api2') - response = self.app.get('/api2/person/1') - document = loads(response.data) - person = document['data'] - assert person['attributes']['foo'] == 'bar' - - def test_serialization_exception(self): - """Tests that exceptions are caught when a custom serialization method - raises an exception. - - """ - person = self.Person(id=1) - self.session.add(person) - self.session.commit() - - def serializer(instance, *args, **kw): - raise SerializationException(instance) - - self.manager.create_api(self.Person, serializer=serializer, - url_prefix='/api2') - response = self.app.get('/api2/person/1') - assert response.status_code == 500 - # TODO check error message - - def test_serialization_exception_on_included(self): - """Tests that exceptions are caught when a custom serialization method - raises an exception when serializing an included resource. - - """ - person = self.Person(id=1) - article = self.Article(id=1, title=u'') - article.author = person - self.session.add_all([article, person]) - self.session.commit() - - def serializer(instance, **kw): - # Only raise an exception when serializing the included resources. - if isinstance(instance, self.Article): - raise SerializationException(instance) - return simple_serialize(instance, **kw) - - self.manager.create_api(self.Person, serializer=serializer, - url_prefix='/api2') - query_string = {'include': 'articles'} - response = self.app.get('/api2/person/1', query_string=query_string) - assert response.status_code == 500 - # TODO check error message - - def test_circular_includes(self): - """Tests that circular includes are only included once.""" - person1 = self.Person(id=1) - person2 = self.Person(id=2) - comment1 = self.Comment(id=1) - comment2 = self.Comment(id=2) - article1 = self.Article(id=1, title=u'') - article2 = self.Article(id=2, title=u'') - comment1.article = article1 - comment2.article = article2 - comment1.author = person1 - comment2.author = person2 - article1.author = person1 - article2.author = person1 - resources = [article1, article2, comment1, comment2, person1, person2] - self.session.add_all(resources) - self.session.commit() - # The response to this request should include person1 once (for - # the first 'author') and person 2 once (for the last 'author'). - query_string = {'include': 'author.articles.comments.author'} - response = self.app.get('/api/comment/1', query_string=query_string) - document = loads(response.data) - included = document['included'] - # Sort the included resources, first by type, then by ID. - resources = sorted(included, key=lambda x: (x['type'], x['id'])) - resource_types = [resource['type'] for resource in resources] - resource_ids = [resource['id'] for resource in resources] - # We expect two articles, two persons, and one comment (since - # the other comment is the primary data in the response - # document). - expected_types = ['article', 'article', 'comment', 'person', 'person'] - expected_ids = ['1', '2', '2', '1', '2'] - assert expected_types == resource_types - assert expected_ids == resource_ids - def test_attributes_in_url(self): """Tests that a user attempting to access an attribute in the URL instead of a relation yields a meaningful error response. @@ -619,6 +402,7 @@ def test_attributes_in_url(self): person = self.Person(id=1) self.session.add(person) self.session.commit() + self.manager.create_api(self.Person) response = self.app.get('/api/person/1/id') check_sole_error(response, 404, ['No such relation', 'id']) @@ -663,70 +447,6 @@ def test_nonexistent_relation(self): assert response.status_code == 404 # TODO Check error message here. - def test_serialization_exception_to_many(self): - """Tests that exceptions are caught when a custom serialization method - raises an exception on a to-one relation. - - """ - person = self.Person(id=1) - article = self.Article(id=1) - article.author = person - self.session.add_all([person, article]) - self.session.commit() - - def serializer(instance, *args, **kw): - raise SerializationException(instance) - - self.manager.create_api(self.Person, serializer=serializer, - url_prefix='/api2') - response = self.app.get('/api2/person/1/articles') - assert response.status_code == 500 - # TODO check error message - - def test_serialization_exception_to_one(self): - """Tests that exceptions are caught when a custom serialization method - raises an exception on a to-one relation. - - """ - person = self.Person(id=1) - article = self.Article(id=1) - article.author = person - self.session.add_all([person, article]) - self.session.commit() - - def serializer(instance, *args, **kw): - raise SerializationException(instance) - - self.manager.create_api(self.Article, serializer=serializer, - url_prefix='/api2') - response = self.app.get('/api2/article/1/author') - assert response.status_code == 500 - # TODO check error message - - def test_serialization_exception_on_included(self): - """Tests that exceptions are caught when a custom serialization method - raises an exception when serializing an included resource. - - """ - person = self.Person(id=1) - article = self.Article(id=1) - article.author = person - self.session.add_all([article, person]) - self.session.commit() - - def serializer(instance, **kw): - # Only raise an exception when serializing the included resources. - if isinstance(instance, self.Person): - raise SerializationException(instance) - return simple_serialize(instance, **kw) - - self.manager.create_api(self.Person, serializer=serializer, - url_prefix='/api2') - params = {'include': 'author'} - response = self.app.get('/api2/person/1/articles', query_string=params) - assert response.status_code == 500 - # TODO check error message - def test_to_many_pagination(self): """Tests that fetching a to-many relation obeys pagination. @@ -901,87 +621,6 @@ def test_nonexistent_relation(self): assert response.status_code == 404 # TODO Check error message here. - def test_serialization_exception(self): - """Tests that exceptions are caught when a custom serialization method - raises an exception. - - """ - person = self.Person(id=1) - article = self.Article(id=1) - article.author = person - self.session.add_all([person, article]) - self.session.commit() - - def serializer(instance, *args, **kw): - raise SerializationException(instance) - - self.manager.create_api(self.Person, serializer=serializer, - url_prefix='/api2') - response = self.app.get('/api2/person/1/articles/1') - assert response.status_code == 500 - # TODO check error message - - def test_serialization_exception_on_included(self): - """Tests that exceptions are caught when a custom serialization method - raises an exception when serializing an included resource. - - """ - person = self.Person(id=1) - article = self.Article(id=1) - article.author = person - self.session.add_all([article, person]) - self.session.commit() - - def serializer(instance, **kw): - # Only raise an exception when serializing the included resources. - if isinstance(instance, self.Person): - raise SerializationException(instance) - return simple_serialize(instance, **kw) - - self.manager.create_api(self.Person, serializer=serializer, - url_prefix='/api2') - query_string = {'include': 'author'} - response = self.app.get('/api2/person/1/articles/1', - query_string=query_string) - assert response.status_code == 500 - # TODO check error message - - def test_multiple_serialization_exceptions_on_included(self): - """Tests that multiple exceptions are caught when a custom - serialization method raises an exception when serializing an - included resource. - - """ - person1 = self.Person(id=1) - person2 = self.Person(id=2) - article1 = self.Article(id=1) - article2 = self.Article(id=2) - article1.author = person1 - article2.author = person2 - self.session.add_all([article1, article2, person1, person2]) - self.session.commit() - - def serializer(instance, **kw): - # Only raise an exception when serializing the included resources. - if isinstance(instance, self.Person): - raise SerializationException(instance) - return simple_serialize(instance, **kw) - - self.manager.create_api(self.Article, serializer=serializer, - url_prefix='/api2') - query_string = {'include': 'author'} - response = self.app.get('/api2/article', query_string=query_string) - assert response.status_code == 500 - document = loads(response.data) - errors = document['errors'] - assert len(errors) == 2 - error1, error2 = errors - assert error1['status'] == 500 - assert error2['status'] == 500 - assert 'Failed to serialize resource' in error1['detail'] - assert 'ID 1' in error1['detail'] or 'ID 1' in error2['detail'] - assert 'ID 2' in error1['detail'] or 'ID 2' in error2['detail'] - class TestFetchRelationship(ManagerTestBase): """Tests for fetching from a relationship URL.""" @@ -1221,18 +860,31 @@ def test_additional_attributes_object(self): article.comments = [comment1, comment2] self.session.add_all([article, comment1, comment2]) self.session.commit() + + def add_foo(instance, *args, **kw): + result = simple_serialize(instance) + if 'attributes' not in result: + result['attributes'] = {} + result['attributes']['foo'] = 'foo' + return result + self.manager.create_api(self.Article, additional_attributes=['first_comment']) - # HACK Need to create APIs for these other models because otherwise + # Ensure that the comment object has a custom serialization + # function, so we can test that it is serialized using this + # function in particular. + self.manager.create_api(self.Comment, serializer=add_foo) + # HACK Need to create an API for this model because otherwise # we're not able to create the link URLs to them. - self.manager.create_api(self.Comment) self.manager.create_api(self.Person) + response = self.app.get('/api/article/1') document = loads(response.data) article = document['data'] first_comment = article['attributes']['first_comment'] assert first_comment['id'] == '1' assert first_comment['type'] == 'comment' + assert first_comment['attributes']['foo'] == 'foo' def test_exclude(self): """Test for excluding columns from a resource's representation.""" diff --git a/tests/test_manager.py b/tests/test_manager.py index 2fa0e928..65fbca62 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -29,6 +29,7 @@ from flask.ext.restless import collection_name from flask.ext.restless import IllegalArgumentError from flask.ext.restless import model_for +from flask.ext.restless import serializer_for from flask.ext.restless import url_for from .helpers import DatabaseTestBase @@ -290,6 +291,7 @@ def teardown(self): model_for.created_managers.clear() url_for.created_managers.clear() collection_name.created_managers.clear() + serializer_for.created_managers.clear() def test_url_for(self): """Tests the global :func:`flask.ext.restless.url_for` function.""" @@ -331,6 +333,25 @@ def test_collection_name_nonexistent(self): """ collection_name(self.Person) + def test_serializer_for(self): + """Tests the global :func:`flask.ext.restless.serializer_for` + function. + + """ + def my_function(*args, **kw): + pass + + self.manager.create_api(self.Person, serializer=my_function) + assert serializer_for(self.Person) == my_function + + @raises(ValueError) + def test_serializer_for_nonexistent(self): + """Tests that attempting to get the serializer for an unknown + model yields an error. + + """ + serializer_for(self.Person) + def test_model_for(self): """Tests the global :func:`flask.ext.restless.model_for` function.""" self.manager.create_api(self.Person, collection_name='people') diff --git a/tests/test_serialization.py b/tests/test_serialization.py new file mode 100644 index 00000000..335d3389 --- /dev/null +++ b/tests/test_serialization.py @@ -0,0 +1,499 @@ +# test_serialization.py - unit tests for serializing resources +# +# Copyright 2011 Lincoln de Sousa . +# Copyright 2012, 2013, 2014, 2015, 2016 Jeffrey Finkelstein +# and contributors. +# +# This file is part of Flask-Restless. +# +# Flask-Restless is distributed under both the GNU Affero General Public +# License version 3 and under the 3-clause BSD license. For more +# information, see LICENSE.AGPL and LICENSE.BSD. +"""Unit tests for serializing resources. + +This module complements the tests in :mod:`test_fetching` module; tests +in this class should still be testing the behavior of Flask-Restless by +making requests to endpoints created by :meth:`APIManager.create_api`, +not by calling the serialization functions directly. This helps keep the +testing code decoupled from the serialization implementation. + +""" +from datetime import datetime +from datetime import time +from uuid import uuid1 + +from sqlalchemy import Column +from sqlalchemy import Date +from sqlalchemy import DateTime +from sqlalchemy import ForeignKey +from sqlalchemy import Integer +from sqlalchemy import Time +from sqlalchemy import Unicode +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import backref +from sqlalchemy.orm import relationship + +from flask.ext.restless import simple_serialize +from flask.ext.restless import SerializationException + +from .helpers import check_sole_error +from .helpers import GUID +from .helpers import loads +from .helpers import ManagerTestBase + + +def raise_exception(instance, *args, **kw): + """Immediately raises a :exc:`SerializationException` with access to + the provided `instance` of a SQLAlchemy model. + + This function is useful for use in tests for serialization + exceptions. + + """ + raise SerializationException(instance) + + +class TestFetchCollection(ManagerTestBase): + + def setup(self): + super(TestFetchCollection, self).setup() + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + + self.Person = Person + self.Base.metadata.create_all() + + def test_exception_single(self): + """Tests for a serialization exception on a filtered single + response. + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + + self.manager.create_api(self.Person, serializer=raise_exception) + + query_string = {'filter[single]': 1} + response = self.app.get('/api/person', query_string=query_string) + check_sole_error(response, 500, ['Failed to serialize', 'type', + 'person', 'ID', '1']) + + +class TestFetchResource(ManagerTestBase): + """Tests for serializing when fetching from a resource endpoint.""" + + def setup(self): + super(TestFetchResource, self).setup() + + class Article(self.Base): + __tablename__ = 'article' + id = Column(Integer, primary_key=True) + author_id = Column(Integer, ForeignKey('person.id')) + author = relationship('Person', backref=backref('articles')) + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + uuid = Column(GUID) + name = Column(Unicode) + bedtime = Column(Time) + birth_datetime = Column(DateTime) + birthday = Column(Date) + + @hybrid_property + def has_early_bedtime(self): + if not hasattr(self, 'bedtime') or self.bedtime is None: + return False + nine_oclock = time(21) + return self.bedtime < nine_oclock + + class Comment(self.Base): + __tablename__ = 'comment' + id = Column(Integer, primary_key=True) + author_id = Column(Integer, ForeignKey('person.id')) + author = relationship('Person', backref=backref('comments')) + article_id = Column(Integer, ForeignKey('article.id')) + article = relationship('Article', backref=backref('comments')) + + self.Article = Article + self.Comment = Comment + self.Person = Person + self.Base.metadata.create_all() + + def test_hybrid_property(self): + """Tests for fetching a resource with a hybrid property attribute.""" + person1 = self.Person(id=1, bedtime=time(20)) + person2 = self.Person(id=2, bedtime=time(22)) + self.session.add_all([person1, person2]) + self.session.commit() + self.manager.create_api(self.Person) + response = self.app.get('/api/person/1') + document = loads(response.data) + person = document['data'] + assert person['attributes']['has_early_bedtime'] + response = self.app.get('/api/person/2') + document = loads(response.data) + person = document['data'] + assert not person['attributes']['has_early_bedtime'] + + def test_uuid(self): + """Tests for serializing a (non-primary key) UUID field.""" + uuid = uuid1() + person = self.Person(id=1, uuid=uuid) + self.session.add(person) + self.session.commit() + self.manager.create_api(self.Person) + response = self.app.get('/api/person/1') + assert response.status_code == 200 + document = loads(response.data) + person = document['data'] + assert person['attributes']['uuid'] == str(uuid) + + def test_time(self): + """Test for getting the JSON representation of a time field.""" + now = datetime.now().time() + person = self.Person(id=1, bedtime=now) + self.session.add(person) + self.session.commit() + self.manager.create_api(self.Person) + response = self.app.get('/api/person/1') + assert response.status_code == 200 + document = loads(response.data) + person = document['data'] + assert person['attributes']['bedtime'] == now.isoformat() + + def test_datetime(self): + """Test for getting the JSON representation of a datetime field.""" + now = datetime.now() + person = self.Person(id=1, birth_datetime=now) + self.session.add(person) + self.session.commit() + self.manager.create_api(self.Person) + response = self.app.get('/api/person/1') + assert response.status_code == 200 + document = loads(response.data) + person = document['data'] + assert person['attributes']['birth_datetime'] == now.isoformat() + + def test_date(self): + """Test for getting the JSON representation of a date field.""" + now = datetime.now().date() + person = self.Person(id=1, birthday=now) + self.session.add(person) + self.session.commit() + self.manager.create_api(self.Person) + response = self.app.get('/api/person/1') + assert response.status_code == 200 + document = loads(response.data) + person = document['data'] + assert person['attributes']['birthday'] == now.isoformat() + + def test_custom_function(self): + """Tests for a custom serialization function.""" + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + + def serializer(instance, **kw): + result = simple_serialize(instance, **kw) + result['attributes']['foo'] = 'bar' + return result + + self.manager.create_api(self.Person, serializer=serializer) + response = self.app.get('/api/person/1') + document = loads(response.data) + person = document['data'] + assert person['attributes']['foo'] == 'bar' + + def test_per_model_serializer_on_included(self): + """Tests that a response that includes resources of multiple + types respects the model-specific serializers provided to the + :meth:`APIManager.create_api` method when called with different + model classes. + + """ + person = self.Person(id=1) + article = self.Article(id=1) + article.author = person + self.session.add_all([person, article]) + self.session.commit() + + def add_foo(instance, *args, **kw): + result = simple_serialize(instance, *args, **kw) + if 'attributes' not in result: + result['attributes'] = {} + result['attributes']['foo'] = 'foo' + return result + + def add_bar(instance, *args, **kw): + result = simple_serialize(instance, *args, **kw) + if 'attributes' not in result: + result['attributes'] = {} + result['attributes']['bar'] = 'bar' + return result + + self.manager.create_api(self.Person, serializer=add_foo) + self.manager.create_api(self.Article, serializer=add_bar) + + query_string = {'include': 'author'} + response = self.app.get('/api/article/1', query_string=query_string) + document = loads(response.data) + # First, the article resource should have an extra 'bar' attribute. + article = document['data'] + assert article['attributes']['bar'] == 'bar' + assert 'foo' not in article['attributes'] + # Second, there should be a single included resource, a person + # with a 'foo' attribute. + included = document['included'] + assert len(included) == 1 + author = included[0] + assert author['attributes']['foo'] == 'foo' + assert 'bar' not in author['attributes'] + + def test_exception(self): + """Tests that exceptions are caught when a custom serialization method + raises an exception. + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + + self.manager.create_api(self.Person, serializer=raise_exception) + + response = self.app.get('/api/person/1') + check_sole_error(response, 500, ['Failed to serialize', 'type', + 'person', 'ID', '1']) + + def test_exception_on_included(self): + """Tests that exceptions are caught when a custom serialization method + raises an exception when serializing an included resource. + + """ + person = self.Person(id=1) + article = self.Article(id=1) + article.author = person + self.session.add_all([article, person]) + self.session.commit() + + self.manager.create_api(self.Person) + self.manager.create_api(self.Article, serializer=raise_exception) + + query_string = {'include': 'articles'} + response = self.app.get('/api/person/1', query_string=query_string) + check_sole_error(response, 500, ['Failed to serialize', 'type', + 'article', 'ID', '1']) + + def test_multiple_exceptions_on_included(self): + """Tests that multiple serialization exceptions are caught when + a custom serialization method raises an exception when + serializing an included resource. + + """ + person1 = self.Person(id=1) + person2 = self.Person(id=2) + article1 = self.Article(id=1) + article2 = self.Article(id=2) + article1.author = person1 + article2.author = person2 + self.session.add_all([article1, article2, person1, person2]) + self.session.commit() + + self.manager.create_api(self.Article) + self.manager.create_api(self.Person, serializer=raise_exception) + + query_string = {'include': 'author'} + response = self.app.get('/api/article', query_string=query_string) + assert response.status_code == 500 + document = loads(response.data) + errors = document['errors'] + assert len(errors) == 2 + error1, error2 = errors + assert error1['status'] == 500 + assert error2['status'] == 500 + assert 'Failed to serialize included resource' in error1['detail'] + assert 'Failed to serialize included resource' in error2['detail'] + assert 'ID 1' in error1['detail'] or 'ID 1' in error2['detail'] + assert 'ID 2' in error1['detail'] or 'ID 2' in error2['detail'] + + def test_circular_includes(self): + """Tests that circular includes are only included once.""" + person1 = self.Person(id=1) + person2 = self.Person(id=2) + comment1 = self.Comment(id=1) + comment2 = self.Comment(id=2) + article1 = self.Article(id=1) + article2 = self.Article(id=2) + comment1.article = article1 + comment2.article = article2 + comment1.author = person1 + comment2.author = person2 + article1.author = person1 + article2.author = person1 + resources = [article1, article2, comment1, comment2, person1, person2] + self.session.add_all(resources) + self.session.commit() + + self.manager.create_api(self.Article) + self.manager.create_api(self.Comment) + self.manager.create_api(self.Person) + + # The response to this request should include person1 once (for + # the first 'author') and person 2 once (for the last 'author'). + query_string = {'include': 'author.articles.comments.author'} + response = self.app.get('/api/comment/1', query_string=query_string) + document = loads(response.data) + included = document['included'] + # Sort the included resources, first by type, then by ID. + resources = sorted(included, key=lambda x: (x['type'], x['id'])) + resource_types = [resource['type'] for resource in resources] + resource_ids = [resource['id'] for resource in resources] + # We expect two articles, two persons, and one comment (since + # the other comment is the primary data in the response + # document). + expected_types = ['article', 'article', 'comment', 'person', 'person'] + expected_ids = ['1', '2', '2', '1', '2'] + assert expected_types == resource_types + assert expected_ids == resource_ids + + +class TestFetchRelation(ManagerTestBase): + + def setup(self): + super(TestFetchRelation, self).setup() + + class Article(self.Base): + __tablename__ = 'article' + id = Column(Integer, primary_key=True) + author_id = Column(Integer, ForeignKey('person.id')) + author = relationship('Person', backref=backref('articles')) + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + + self.Article = Article + self.Person = Person + self.Base.metadata.create_all() + self.manager.create_api(Article) + self.manager.create_api(Person) + + def test_exception_to_many(self): + """Tests that exceptions are caught when a custom serialization method + raises an exception on a to-one relation. + + """ + person = self.Person(id=1) + article = self.Article(id=1) + article.author = person + self.session.add_all([person, article]) + self.session.commit() + + self.manager.create_api(self.Person) + self.manager.create_api(self.Article, serializer=raise_exception) + + response = self.app.get('/api/person/1/articles') + check_sole_error(response, 500, ['Failed to serialize', 'type', + 'article', 'ID', '1']) + + def test_exception_to_one(self): + """Tests that exceptions are caught when a custom serialization method + raises an exception on a to-one relation. + + """ + person = self.Person(id=1) + article = self.Article(id=1) + article.author = person + self.session.add_all([person, article]) + self.session.commit() + + self.manager.create_api(self.Person, serializer=raise_exception) + self.manager.create_api(self.Article) + + response = self.app.get('/api/article/1/author') + check_sole_error(response, 500, ['Failed to serialize', 'type', + 'person', 'ID', '1']) + + def test_exception_on_included(self): + """Tests that exceptions are caught when a custom serialization method + raises an exception when serializing an included resource. + + """ + person = self.Person(id=1) + article = self.Article(id=1) + article.author = person + self.session.add_all([article, person]) + self.session.commit() + + self.manager.create_api(self.Person, serializer=raise_exception) + self.manager.create_api(self.Article) + + params = {'include': 'author'} + response = self.app.get('/api/person/1/articles', query_string=params) + assert response.status_code == 500 + check_sole_error(response, 500, ['Failed to serialize', + 'included resource', 'type', 'person', + 'ID', '1']) + + +class TestFetchRelatedResource(ManagerTestBase): + """Tests for serializing when fetching from a related resource endpoint.""" + + def setup(self): + super(TestFetchRelatedResource, self).setup() + + class Article(self.Base): + __tablename__ = 'article' + id = Column(Integer, primary_key=True) + author_id = Column(Integer, ForeignKey('person.id')) + author = relationship('Person', backref=backref('articles')) + + class Person(self.Base): + __tablename__ = 'person' + id = Column(Integer, primary_key=True) + + self.Article = Article + self.Person = Person + self.Base.metadata.create_all() + + def test_exception(self): + """Tests that serialization exceptions are caught when fetching + a related resource. + + """ + person = self.Person(id=1) + article = self.Article(id=1) + article.author = person + self.session.add_all([person, article]) + self.session.commit() + + self.manager.create_api(self.Person) + self.manager.create_api(self.Article, serializer=raise_exception) + + response = self.app.get('/api/person/1/articles/1') + check_sole_error(response, 500, ['Failed to serialize', 'type', + 'article', 'ID', '1']) + + def test_exception_on_included(self): + """Tests that serialization exceptions are caught for included + resource on a request to fetch a related resource. + + """ + person = self.Person(id=1) + article = self.Article(id=1) + article.author = person + self.session.add_all([article, person]) + self.session.commit() + + self.manager.create_api(self.Person, serializer=raise_exception) + self.manager.create_api(self.Article) + + query_string = {'include': 'author'} + response = self.app.get('/api/person/1/articles/1', + query_string=query_string) + check_sole_error(response, 500, ['Failed to serialize', + 'included resource', 'type', 'person', + 'ID', '1'])