diff --git a/CHANGES b/CHANGES index 32fc8855..1f1187f3 100644 --- a/CHANGES +++ b/CHANGES @@ -17,7 +17,10 @@ Version 1.0.0b2-dev Not yet released. +- Changes serialization/deserialization to class-based implementation instead + of a function-based implementation. - :issue:`7`: allows filtering before function evaluation. +- :issue:`49`: deserializers now expect a complete JSON API document. Version 1.0.0b1 --------------- diff --git a/docs/api.rst b/docs/api.rst index 554ccdab..76717b8f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -30,19 +30,19 @@ Global helper functions .. autofunction:: url_for(model, instid=None, relationname=None, relationinstid=None, _apimanager=None, **kw) -Serialization helpers ---------------------- - -.. autofunction:: simple_serialize(instance, only=None) +Serialization and deserialization +--------------------------------- -.. autoclass:: Serializer +.. autoclass:: DefaultSerializer -.. autoclass:: Deserializer +.. autoclass:: DefaultDeserializer .. autoclass:: SerializationException .. autoclass:: DeserializationException +.. autoclass:: MultipleExceptions + Pre- and postprocessor helpers ------------------------------ diff --git a/docs/customizing.rst b/docs/customizing.rst index feaee5ca..b63ecb71 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -128,57 +128,90 @@ Custom serialization ~~~~~~~~~~~~~~~~~~~~ .. versionadded:: 0.17.0 +.. versionchanged:: 1.0.0b1 + + Transitioned from function-based serialization to class-based serialization. Flask-Restless provides serialization and deserialization that work with the JSON API specification. If you wish to have more control over the way instances of your models are converted to Python dictionary representations, -you can specify a custom serialization function by providing it to -:meth:`APIManager.create_api` via the ``serializer`` keyword argument. -Similarly, to provide a deserialization function that converts a Python -dictionary representation to an instance of your model, use the -``deserializer`` keyword argument. However, if you provide a serializer that -fails to produce resource objects that satisfy the JSON API specification, your -client will receive non-compliant responses! - -Define your serialization functions like this:: - - def serialize(instance, only=None): - return {'id': ..., 'type': ..., 'attributes': ...} - -``instance`` is an instance of a SQLAlchemy model and the ``only`` argument is -a list; only the fields (that is, the attributes and relationships) whose names -appear as strings in `only` should appear in the returned dictionary. The only -exception is that the keys ``'id'`` and ``'type'`` must always appear, -regardless of whether they appear in `only`. The function must return a -dictionary representation of the resource object. - -To help with creating custom serialization functions, Flask-Restless provides a -:func:`simple_serialize` function, which returns the result of its basic, -built-in serialization. Therefore, one way to customize your serialized objects -is to do something like this:: - - from flask.ext.restless import simple_serialize - - def my_serializer(instance, only=None): - # Get the default serialization of the instance. - result = simple_serialize(instance, only=only) - # Make your changes here. - result['meta']['foo'] = 'bar' - # Return the dictionary. - return result - -You could also define a subclass of the :class:`DefaultSerializer` class, -override the :meth:`DefaultSerializer.__call__` method, and provide an instance -of that class to the `serializer` keyword argument. - -For deserialization, define your custom deserialization function like this:: - - def deserialize(document): - return Person(...) +you can specify custom serialization by providing it to +:meth:`APIManager.create_api` via the ``serializer_class`` keyword argument. +Similarly, to provide a deserializer that converts a Python dictionary +representation to an instance of your model, use the ``deserializer_class`` +keyword argument. However, if you provide a serializer that fails to produce +resource objects that satisfy the JSON API specification, your client will +receive non-compliant responses! + +Your serializer classes must be a subclass of +:class:`~flask.ext.restless.Serializer` and can override the +:meth:`~flask.ext.restless.Serializer.serialize` and +:meth:`~flask.ext.restless.Serializer.serialize_many` methods to provide custom +serialization (however, we recommend overriding the +:class:`~flask.ext.restless.DefaultSerializer` class, as it provides some +useful behavior in its constructor). These methods take an instance or +instances as input and return a dictionary representing a JSON API +document. Each also accepts an ``only`` keyword argument, indicating the sparse +fieldsets requested by the client. When implementing your custom serializer, +you may wish to override the :class:`flask.ext.restless.DefaultSerializer` +class:: + + from flask.ext.restless import DefaultSerializer + + class MySerializer(DefaultSerializer): + + def serialize(self, instance, only=None): + super_serialize = super(DefaultSerializer, self).serialize + document = super_serialize(instance, only=only) + # Make changes to the document here... + ... + return document + + def serialize_many(self, instances, only=None): + super_serialize = super(DefaultSerializer, self).serialize_many + document = super_serialize(instances, only=only) + # Make changes to the document here... + ... + return document + +``instance`` is an instance of a SQLAlchemy model, ``instances`` is a list of +instances, and the ``only`` argument is a list; only the fields (that is, the +attributes and relationships) whose names appear as strings in `only` should +appear in the returned dictionary. The only exception is that the keys ``'id'`` +and ``'type'`` must always appear, regardless of whether they appear in +`only`. The function must return a dictionary representation of the resource +object. + +Flask-Restless also provides functional access to the default serialization, +via the :func:`~flask.ext.restless.simple_serialize` and +:func:`~flask.ext.restless.simple_serialize_many` functions, which return the +result of the built-in default serialization. + +For deserialization, define your custom deserialization class like this:: + + from flask.ext.restless import DefaultDeserializer + + class MyDeserializer(DefaultDeserializer): + + def deserialize(self, document): + return Person(...) ``document`` is a dictionary representation of the *complete* incoming JSON API -document, where the ``data`` element contains the primary resource object. The -function must return an instance of the model that has the requested fields. +document, where the ``data`` element contains the primary resource object or +objects. The function must return an instance of the model that has the +requested fields. If you override the constructor, it must take two positional +arguments, `session` and `model`. + +Your code can raise a :exc:`~flask.ext.restless.SerializationException` when +overriding the :meth:`DefaultSerializer.serialize` method, and similarly a +:exc:`~flask.ext.restless.DeserializationException` in the +:meth:`DefaultDeserializer.deserialize` method; Flask-Restless will +automatically catch those exceptions and format a `JSON API error response`_. +If you wish to collect multiple exceptions (for example, if several fields of a +resource provided to the :meth:`deserialize` method fail validation) you can +raise a :exc:`~flask.ext.restless.MultipleExceptions` exception, providing a +list of other serialization or deserialization exceptions at instantiation +time. .. note:: @@ -197,26 +230,42 @@ follows:: name = fields.String() def make_object(self, data): - print('MAKING OBJECT FROM', data) return Person(**data) - person_schema = PersonSchema() + class PersonSerializer(DefaultSerializer): + + def serialize(self, instance, only=None): + person_schema = PersonSchema(only=only) + return person_schema.dump(instance).data + + def serialize_many(self, instances, only=None): + person_schema = PersonSchema(many=True, only=only) + return person_schema.dump(instances).data + + + class PersonDeserializer(DefaultDeserializer): - def person_serializer(instance): - return person_schema.dump(instance).data + def deserialize(self, document): + person_schema = PersonSchema() + return person_schema.load(instance).data - def person_deserializer(data): - return person_schema.load(data).data + # # JSON API doesn't currently allow bulk creation of resources. When + # # it does, either in the specification or in an extension, this is + # # how you would implement it. + # def deserialize_many(self, document): + # person_schema = PersonSchema(many=True) + # return person_schema.load(instance).data manager = APIManager(app, session=session) manager.create_api(Person, methods=['GET', 'POST'], - serializer=person_serializer, - deserializer=person_deserializer) + serializer_class=PersonSerializer, + deserializer_class=PersonDeserializer) 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`_. +.. _JSON API error response: http://jsonapi.org/format/#errors .. _Marshmallow: https://marshmallow.readthedocs.org .. _view it online: https://github.com/jfinkels/flask-restless/tree/master/examples/server_configurations/custom_serialization.py diff --git a/examples/server_configurations/custom_serialization.py b/examples/server_configurations/custom_serialization.py index cb649b7a..2c09e1c6 100644 --- a/examples/server_configurations/custom_serialization.py +++ b/examples/server_configurations/custom_serialization.py @@ -1,53 +1,192 @@ -""" - Using Marshmallow for serialization - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# custom_serialization.py - using Marshmallow serialization with Flask-Restless +# +# 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. +"""Using Marshmallow for serialization in Flask-Restless. + +This script is an example of using `Marshmallow`_ to provide custom +serialization (and the corresponding deserialization) from SQLAlchemy +models to Python dictionaries that will eventually become JSON API +responses to the client. Specifically, this example uses the +`marshmallow-jsonapi`_ library to create serialization/deserialization +functions for use with Flask-Restless. + +There are some problems with this approach. You will need to specify +some configuration twice, once for marshmallow-jsonapi and once for +Flask-Restless. For example, you must provide a custom "collection name" +as both the class-level attribute :attr:`Meta.type_` and as the +*collection_name* keyword argument to :meth:`APIManager.create_api`. For +another example, you must specify the URLs for relationships and related +resources directly in the schema definition, and thus these must match +EXACTLY the URLs created by Flask-Restless. The URLs created by +Flask-Restless are fairly predictable, so this requirement, although not +ideal, should not be too challenging. + +(This example might have used the `marshmallow-sqlalchemy`_ model to +mitigate some of these issues, but there does not seem to be an easy way +to combine these two Marshmallow layers together.) + +To install the necessary requirements for this example, run:: + + pip install flask-restless flask-sqlalchemy marshmallow-jsonapi - This provides an example of using `Marshmallow - `_ schema to provide custom - serialization from SQLAlchemy models to Python dictionaries and the - converse deserialization. +To run this script from the current directory:: - :copyright: 2015 Jeffrey Finkelstein - :license: GNU AGPLv3+ or BSD + python -m custom_serialization + +This will run a Flask server at ``http://localhost:5000``. You can then +make requests using any web client. + +.. _Marshmallow: https://marshmallow.readthedocs.org +.. _marshmallow-sqlalchemy: https://marshmallow-sqlalchemy.readthedocs.org +.. _marshmallow-jsonapi: https://marshmallow-jsonapi.readthedocs.org """ from flask import Flask -from flask.ext.sqlalchemy import SQLAlchemy from flask.ext.restless import APIManager -from marshmallow import Schema -from marshmallow import fields +from flask.ext.restless import DefaultSerializer +from flask.ext.restless import DefaultDeserializer +from flask.ext.sqlalchemy import SQLAlchemy +from marshmallow import post_load +from marshmallow_jsonapi import fields +from marshmallow_jsonapi import Schema + +## Flask application and database configuration ## app = Flask(__name__) app.config['DEBUG'] = True app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/test.db' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False db = SQLAlchemy(app) +## Flask-SQLAlchemy model definitions ## + class Person(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.Unicode) - #birth_date = db.Column(db.Date) +class Article(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.Unicode) + author_id = db.Column(db.Unicode, db.ForeignKey(Person.id)) + author = db.relationship(Person, backref=db.backref('articles')) + + +## Marshmallow schema definitions ## + class PersonSchema(Schema): - id = fields.Integer() - name = fields.String() - def make_object(self, data): + class Meta: + model = Person + type_ = 'person' + sqla_session = db.session + strict = True + + id = fields.Integer(dump_only=True) + name = fields.Str() + + articles = fields.Relationship( + self_url='/api/person/{personid}/relationships/articles', + self_url_kwargs={'personid': ''}, + related_url='/api/article/{articleid}', + related_url_kwargs={'articleid': ''}, + many=True, + include_data=True, + type_='articles', + ) + + @post_load + def make_person(self, data): return Person(**data) -person_schema = PersonSchema() -def person_serializer(instance): - return person_schema.dump(instance).data +class ArticleSchema(Schema): + + class Meta: + model = Article + type_ = 'article' + sqla_session = db.session + strict = True + + id = fields.Integer(dump_only=True) + title = fields.Str() + + author = fields.Relationship( + self_url='/api/article/{articleid}/relationships/author', + self_url_kwargs={'articleid': ''}, + related_url='/api/person/{personid}', + related_url_kwargs={'personid': ''}, + include_data=True, + type_='author' + ) + + @post_load + def make_article(self, data): + return Article(**data) + + +## Serializer and deserializer classes ## + +class MarshmallowSerializer(DefaultSerializer): + + schema_class = None + + def serialize(self, instance, only=None): + schema = self.schema_class(only=only) + return schema.dump(instance).data + + def serialize_many(self, instances, only=None): + schema = self.schema_class(many=True, only=only) + return schema.dump(instances).data + + +class MarshmallowDeserializer(DefaultDeserializer): + + schema_class = None + + def deserialize(self, document): + schema = self.schema_class() + return schema.load(document).data + + def deserialize_many(self, document): + schema = self.schema_class(many=True) + return schema.load(document).data + + +class PersonSerializer(MarshmallowSerializer): + schema_class = PersonSchema + + +class PersonDeserializer(MarshmallowDeserializer): + schema_class = PersonSchema + + +class ArticleSerializer(MarshmallowSerializer): + schema_class = ArticleSchema + + +class ArticleDeserializer(MarshmallowDeserializer): + schema_class = ArticleSchema + -def person_deserializer(data): - return person_schema.load(data).data +if __name__ == '__main__': + db.create_all() + manager = APIManager(app, flask_sqlalchemy_db=db) -db.create_all() -manager = APIManager(app, flask_sqlalchemy_db=db) -manager.create_api(Person, methods=['GET', 'POST'], - serializer=person_serializer, - deserializer=person_deserializer) + manager.create_api(Person, methods=['GET', 'POST'], + serializer_class=PersonSerializer, + deserializer_class=PersonDeserializer) + manager.create_api(Article, methods=['GET', 'POST'], + serializer_class=ArticleSerializer, + deserializer_class=ArticleDeserializer) -app.run() + app.run() diff --git a/flask_restless/__init__.py b/flask_restless/__init__.py index 7caf89ad..03aa5883 100644 --- a/flask_restless/__init__.py +++ b/flask_restless/__init__.py @@ -29,10 +29,12 @@ from .helpers import primary_key_for from .manager import APIManager from .manager import IllegalArgumentError +from .serialization import DefaultDeserializer +from .serialization import DefaultSerializer from .serialization import DeserializationException -from .serialization import Deserializer +from .serialization import MultipleExceptions from .serialization import SerializationException -from .serialization import Serializer from .serialization import simple_serialize +from .serialization import simple_serialize_many from .views import CONTENT_TYPE from .views import ProcessingException diff --git a/flask_restless/helpers.py b/flask_restless/helpers.py index 69960ffd..fbc811da 100644 --- a/flask_restless/helpers.py +++ b/flask_restless/helpers.py @@ -204,17 +204,31 @@ def primary_key_value(instance, as_string=False): return url_quote_plus(result.encode('utf-8')) -def is_like_list(instance, relation): - """Returns ``True`` if and only if the relation of `instance` whose name is - `relation` is list-like. +def is_like_list(model_or_instance, relation): + """Returns ``True`` if and only if the relation of the given model or + instance of a model whose name is `relation` is list-like. - A relation may be like a list if, for example, it is a non-lazy one-to-many - relation, or it is a dynamically loaded one-to-many. + A relation may be like a list if, for example, it is a non-lazy + to-many relation, or it is a dynamically loaded to-many relation. + + `model_or_instance` may be either a SQLAlchemy model class or an + instance of such a class. """ + if inspect.isclass(model_or_instance): + model = model_or_instance + if relation in model._sa_class_manager: + return model._sa_class_manager[relation].property.uselist + related_value = getattr(model, relation) + if isinstance(related_value, AssociationProxy): + local_prop = related_value.local_attr.prop + if isinstance(local_prop, RelProperty): + return local_prop.uselist + return False + instance = model_or_instance if relation in instance._sa_class_manager: return instance._sa_class_manager[relation].property.uselist - elif hasattr(instance, relation): + if hasattr(instance, relation): attr = getattr(instance._sa_instance_state.class_, relation) if hasattr(attr, 'property'): return attr.property.uselist diff --git a/flask_restless/manager.py b/flask_restless/manager.py index f0ecd1d1..c411d84f 100644 --- a/flask_restless/manager.py +++ b/flask_restless/manager.py @@ -73,8 +73,8 @@ #: model exposed by this API. #: - `primary_key`, the primary key used by the model #: -APIInfo = namedtuple('APIInfo', ['collection_name', 'blueprint_name', 'serializer', - 'primary_key']) +APIInfo = namedtuple('APIInfo', ['collection_name', 'blueprint_name', + 'serializer', 'primary_key']) class IllegalArgumentError(Exception): @@ -391,7 +391,7 @@ def create_api_blueprint(self, name, model, methods=READONLY_METHODS, validation_exceptions=None, page_size=10, max_page_size=100, preprocessors=None, postprocessors=None, primary_key=None, - serializer=None, deserializer=None, + serializer_class=None, deserializer_class=None, includes=None, allow_to_many_replacement=False, allow_delete_from_to_many_relationships=False, allow_client_generated_ids=False): @@ -538,16 +538,11 @@ def create_api_blueprint(self, name, model, methods=READONLY_METHODS, at most `max_page_size` results will be returned. For more information, see :ref:`pagination`. - `serializer` and `deserializer` are custom serialization - functions. The former function must take a single positional - argument representing the instance of the model to serialize and - an additional keyword argument ``only`` representing the fields - to include in the serialized representation of the instance, and - must return a dictionary representation of that instance. The - latter function must take a single argument representing the - dictionary representation of an instance of the model and must - return an instance of `model` that has those attributes. For - more information, see :ref:`serialization`. + `serializer_class` and `deserializer_class` are custom + serializer and deserializer classes. The former must be a + subclass of :class:`Serializer` and the latter a subclass of + :class:`Deserializer`. For more information on using these, see + :ref:`serialization`. `preprocessors` is a dictionary mapping strings to lists of functions. Each key represents a type of endpoint (for example, @@ -617,10 +612,8 @@ def create_api_blueprint(self, name, model, methods=READONLY_METHODS, postprocessors_ = defaultdict(list) preprocessors_.update(preprocessors or {}) postprocessors_.update(postprocessors or {}) - # for key, value in self.restless_info.universal_preprocessors.items(): for key, value in self.pre.items(): preprocessors_[key] = value + preprocessors_[key] - # for key, value in self.restless_info.universal_postprocessors.items(): for key, value in self.post.items(): postprocessors_[key] = value + postprocessors_[key] # Validate that all the additional attributes exist on the model. @@ -629,32 +622,34 @@ def create_api_blueprint(self, name, model, methods=READONLY_METHODS, if isinstance(attr, STRING_TYPES) and not hasattr(model, attr): msg = 'no attribute "{0}" on model {1}'.format(attr, model) raise AttributeError(msg) + if (additional_attributes is not None and exclude is not None and + any(attr in exclude for attr in additional_attributes)): + msg = ('Cannot exclude attributes listed in the' + ' `additional_attributes` keyword argument') + raise IllegalArgumentError(msg) # Create a default serializer and deserializer if none have been # provided. - if serializer is None: - serializer = DefaultSerializer(only, exclude, - additional_attributes) - # if validation_exceptions is None: - # validation_exceptions = [DeserializationException] - # else: - # validation_exceptions.append(DeserializationException) - # session = self.restlessinfo.session - session = self.session - if deserializer is None: - deserializer = DefaultDeserializer(self.session, model, - allow_client_generated_ids) + if serializer_class is None: + serializer_class = DefaultSerializer + if deserializer_class is None: + deserializer_class = DefaultDeserializer + # Instantiate the serializer and deserializer. + attrs = additional_attributes + serializer = serializer_class(only=only, exclude=exclude, + additional_attributes=attrs) + acgi = allow_client_generated_ids + deserializer = deserializer_class(self.session, model, + allow_client_generated_ids=acgi) # Create the view function for the API for this model. # # Rename some variables with long names for the sake of brevity. atmr = allow_to_many_replacement - api_view = API.as_view(apiname, session, model, - # Keyword arguments for APIBase.__init__() + api_view = API.as_view(apiname, self.session, model, preprocessors=preprocessors_, postprocessors=postprocessors_, primary_key=primary_key, validation_exceptions=validation_exceptions, allow_to_many_replacement=atmr, - # Keyword arguments for API.__init__() page_size=page_size, max_page_size=max_page_size, serializer=serializer, @@ -697,7 +692,7 @@ def create_api_blueprint(self, name, model, methods=READONLY_METHODS, rapi_view = RelationshipAPI.as_view adftmr = allow_delete_from_to_many_relationships relationship_api_view = \ - rapi_view(relationship_api_name, session, model, + rapi_view(relationship_api_name, self.session, model, # Keyword arguments for APIBase.__init__() preprocessors=preprocessors_, postprocessors=postprocessors_, @@ -761,7 +756,8 @@ def create_api_blueprint(self, name, model, methods=READONLY_METHODS, # evaluating functions on all instances of the specified model if allow_functions: eval_api_name = '{0}.eval'.format(apiname) - eval_api_view = FunctionAPI.as_view(eval_api_name, session, model) + eval_api_view = FunctionAPI.as_view(eval_api_name, self.session, + model) eval_endpoint = '/eval{0}'.format(collection_url) eval_methods = ['GET'] blueprint.add_url_rule(eval_endpoint, methods=eval_methods, diff --git a/flask_restless/serialization.py b/flask_restless/serialization.py deleted file mode 100644 index 0b01d776..00000000 --- a/flask_restless/serialization.py +++ /dev/null @@ -1,829 +0,0 @@ -# serialization.py - JSON serialization for SQLAlchemy models -# -# 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. -"""Classes for JSON serialization of SQLAlchemy models. - -The abstract base classes :class:`Serializer` and :class:`Deserializer` -can be used to implement custom serialization from and deserialization -to SQLAlchemy objects. The :class:`DefaultSerializer` and -:class:`DefaultDeserializer` provide some basic serialization and -deserialization as expected by classes that follow the JSON API -protocol. - -""" -from __future__ import division - -from datetime import date -from datetime import datetime -from datetime import time -from datetime import timedelta -try: - from urllib.parse import urljoin -except ImportError: - from urlparse import urljoin - -from flask import request -from sqlalchemy import Column -from sqlalchemy.exc import NoInspectionAvailable -from sqlalchemy.ext.hybrid import HYBRID_PROPERTY -from sqlalchemy.inspection import inspect -from werkzeug.routing import BuildError -from werkzeug.urls import url_quote_plus - -from .helpers import collection_name -from .helpers import is_mapped_class -from .helpers import foreign_keys -from .helpers import get_by -from .helpers import get_model -from .helpers import get_related_model -from .helpers import get_relations -from .helpers import has_field -from .helpers import is_like_list -from .helpers import primary_key_for -from .helpers import primary_key_value -from .helpers import serializer_for -from .helpers import strings_to_datetimes -from .helpers import url_for - -#: Names of columns which should definitely not be considered user columns to -#: be included in a dictionary representation of a model. -COLUMN_BLACKLIST = ('_sa_polymorphic_on', ) - -# TODO In Python 2.7 or later, we can just use `timedelta.total_seconds()`. -if hasattr(timedelta, 'total_seconds'): - def total_seconds(td): - return td.total_seconds() -else: - # This formula comes from the Python 2.7 documentation for the - # `timedelta.total_seconds` method. - def total_seconds(td): - secs = td.seconds + td.days * 24 * 3600 - return (td.microseconds + secs * 10**6) / 10**6 - - -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 - :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, message=None, resource=None, *args, **kw): - super(SerializationException, self).__init__(*args, **kw) - self.resource = resource - self.message = message - self.instance = instance - - -class DeserializationException(Exception): - """Raised when there is a problem deserializing a Python dictionary to an - instance of a SQLAlchemy model. - - Subclasses that wish to provide more detailed about the problem - should set the ``detail`` attribute to be a string, either as a - class-level attribute or as an instance attribute. - - """ - - def __init__(self, *args, **kw): - super(DeserializationException, self).__init__(*args, **kw) - - #: A string describing the problem in more detail. - #: - #: Subclasses must set this attribute to be a string describing - #: the problem that cause this exception. - self.detail = None - - def message(self): - """Returns a more detailed description of the problem as a - string. - - """ - base = 'Failed to deserialize object' - if self.detail is not None: - return '{0}: {1}'.format(base, self.detail) - return base - - -class ClientGeneratedIDNotAllowed(DeserializationException): - """Raised when attempting to deserialize a resource that provides - an ID when an ID is not allowed. - - """ - - def __init__(self, *args, **kw): - super(ClientGeneratedIDNotAllowed, self).__init__(*args, **kw) - - self.detail = 'Server does not allow client-generated IDS' - - -class ConflictingType(DeserializationException): - """Raised when attempting to deserialize a linkage object with an - unexpected ``'type'`` key. - - `relation_name` is a string representing the name of the - relationship for which a linkage object has a conflicting type. - - `expected_type` is a string representing the expected type of the - related resource. - - `given_type` is is a string representing the given value of the - ``'type'`` element in the resource. - - """ - - def __init__(self, expected_type, given_type, relation_name=None, *args, - **kw): - super(ConflictingType, self).__init__(*args, **kw) - - #: The name of the relationship with a conflicting type. - self.relation_name = relation_name - - #: The expected type name for the related model. - self.expected_type = expected_type - - #: The type name given by the client for the related model. - self.given_type = given_type - - if relation_name is None: - detail = 'expected type "{0}" but got type "{1}"' - detail = detail.format(expected_type, given_type) - else: - detail = ('expected type "{0}" but got type "{1}" in linkage' - ' object for relationship "{2}"') - detail = detail.format(expected_type, given_type, relation_name) - self.detail = detail - - -class UnknownField(DeserializationException): - """Raised when attempting to deserialize an object that references a - field that does not exist on the model. - - `field` is the name of the unknown field as a string. - - """ - - #: Whether the unknown field is given as a field or a relationship. - #: - #: This attribute can only take one of the two values ``'field'`` or - #: ``'relationship'``. - field_type = None - - def __init__(self, field, *args, **kw): - super(UnknownField, self).__init__(*args, **kw) - - #: The name of the unknown field, as a string. - self.field = field - - self.detail = 'model has no {0} "{1}"'.format(self.field_type, field) - - -class UnknownRelationship(UnknownField): - """Raised when attempting to deserialize a linkage object that - references a relationship that does not exist on the model. - - """ - field_type = 'relationship' - - -class UnknownAttribute(UnknownField): - """Raised when attempting to deserialize an object that specifies a - field that does not exist on the model. - - """ - field_type = 'attribute' - - -class MissingInformation(DeserializationException): - """Raised when a linkage object does not specify an element required by - the JSON API specification. - - `relation_name` is the name of the relationship in which the linkage - object is missing information. - - """ - - #: The name of the key in the dictionary that is missing. - #: - #: Subclasses must set this class attribute. - element = None - - def __init__(self, relation_name=None, *args, **kw): - super(MissingInformation, self).__init__(*args, **kw) - - #: The relationship in which a linkage object is missing information. - self.relation_name = relation_name - - if relation_name is None: - detail = 'missing "{0}" element' - detail = detail.format(self.element) - else: - detail = ('missing "{0}" element in linkage object for' - ' relationship "{1}"') - detail = detail.format(self.element, relation_name) - self.detail = detail - - -class MissingData(MissingInformation): - """Raised when a resource does not specify a ``'data'`` element - where required by the JSON API specification. - - """ - element = 'data' - - -class MissingID(MissingInformation): - """Raised when a resource does not specify an ``'id'`` element where - required by the JSON API specification. - - """ - element = 'id' - - -class MissingType(MissingInformation): - """Raised when a resource does not specify a ``'type'`` element - where required by the JSON API specification. - - """ - element = 'type' - - -def get_column_name(column): - """Retrieve a column name from a column attribute of SQLAlchemy model - class, or a string. - - Raises `TypeError` when argument does not fall into either of those - options. - - """ - # TODO use inspection API here - if hasattr(column, '__clause_element__'): - clause_element = column.__clause_element__() - if not isinstance(clause_element, Column): - msg = 'Expected a column attribute of a SQLAlchemy ORM class' - raise TypeError(msg) - return clause_element.key - return column - - -def create_relationship(model, instance, relation): - """Creates a relationship from the given relation name. - - Returns a dictionary representing a relationship as described in - the `Relationships`_ section of the JSON API specification. - - `model` is the model class of the primary resource for which a - relationship object is being created. - - `instance` is the instance of the model for which we are considering - a related value. - - `relation` is the name of the relation of `instance` given as a - string. - - This function may raise :exc:`ValueError` if an API has not been - created for the primary model, `model`, or the model of the - relation. - - .. _Relationships: http://jsonapi.org/format/#document-resource-object-relationships - - """ - result = {} - # Create the self and related links. - pk_value = primary_key_value(instance) - self_link = url_for(model, pk_value, relation, relationship=True) - related_link = url_for(model, pk_value, relation) - result['links'] = {'self': self_link} - # If the user has not created a GET endpoint for the related - # resource, then there is no "related" link to provide, so we check - # whether the URL exists before setting the related link. - try: - related_model = get_related_model(model, relation) - url_for(related_model) - except ValueError: - pass - else: - result['links']['related'] = related_link - # Get the related value so we can see if it is a to-many - # relationship or a to-one relationship. - related_value = getattr(instance, relation) - # There are three possibilities for the relation: it could be a - # to-many relationship, a null to-one relationship, or a non-null - # to-one relationship. We decide whether the relation is to-many by - # determining whether it is list-like. - if is_like_list(instance, relation): - # We could pre-compute the "type" name for the related instances - # here and provide it in the `_type` keyword argument to the - # serialization function, but the to-many relationship could be - # heterogeneous. - result['data'] = [simple_relationship_serialize(instance) - for instance in related_value] - elif related_value is not None: - result['data'] = simple_relationship_serialize(related_value) - else: - result['data'] = None - return result - - -class Serializer(object): - """An object that, when called, returns a dictionary representation of a - given instance of a SQLAlchemy model. - - **This is a base class with no implementation.** - - """ - - def __call__(self, instance, only=None): - """Returns a dictionary representation of the specified instance of a - SQLAlchemy model. - - If `only` is a list, only the fields and relationships whose names - appear as strings in `only` should appear in the returned - dictionary. The only exception is that the keys ``'id'`` and ``'type'`` - will always appear, regardless of whether they appear in `only`. - - **This method is not implemented in this base class; subclasses must - override this method.** - - """ - raise NotImplementedError - - -class Deserializer(object): - """An object that, when called, returns an instance of the SQLAlchemy model - specified at instantiation time. - - `session` is the SQLAlchemy session in which to look for any related - resources. - - `model` is the class of which instances will be created by the - :meth:`__call__` method. - - **This is a base class with no implementation.** - - """ - - def __init__(self, session, model): - self.session = session - self.model = model - - def __call__(self, document): - """Creates and returns a new instance of the SQLAlchemy model specified - in the constructor whose attributes are given by the specified - dictionary. - - `document` must be a dictionary representation of a JSON API - document containing a single resource as primary data, as - specified in the JSON API specification. For more information, - see the `Resource Objects`_ section of the JSON API - specification. - - **This method is not implemented in this base class; subclasses must - override this method.** - - .. _Resource Objects: http://jsonapi.org/format/#document-structure-resource-objects - - """ - raise NotImplementedError - - -class DefaultSerializer(Serializer): - """A default implementation of a serializer for SQLAlchemy models. - - When called, this object returns a dictionary representation of a given - SQLAlchemy instance that meets the requirements of the JSON API - specification. - - If `only` is a list, only these fields and relationships will in the - returned dictionary. The only exception is that the keys ``'id'`` and - ``'type'`` will always appear, regardless of whether they appear in `only`. - These settings take higher priority than the `only` list provided to the - :meth:`__call__` method: if an attribute or relationship appears in the - `only` argument to :meth:`__call__` but not here in the constructor, it - will not appear in the returned dictionary. - - If `exclude` is a list, these fields and relationships will **not** appear - in the returned dictionary. - - If `additional_attributes` is a list, these attributes of the instance to - be serialized will appear in the returned dictionary. This is useful if - your model has an attribute that is not a SQLAlchemy column but you want it - to be exposed. - - If both `only` and `exclude` are specified, a :exc:`ValueError` is raised. - Also, if any attributes specified in `additional_attributes` appears in - `exclude`, a :exc:`ValueError` is raised. - - """ - - def __init__(self, only=None, exclude=None, additional_attributes=None, - **kw): - super(DefaultSerializer, self).__init__(**kw) - if only is not None and exclude is not None: - raise ValueError('Cannot specify both `only` and `exclude` keyword' - ' arguments simultaneously') - if (additional_attributes is not None and exclude is not None and - any(attr in exclude for attr in additional_attributes)): - raise ValueError('Cannot exclude attributes listed in the' - ' `additional_attributes` keyword argument') - # Always include at least the type and ID, regardless of what the user - # specified. - if only is not None: - # Convert SQLAlchemy Column objects to strings if necessary. - # - # TODO In Python 2.7 or later, this should be a set comprehension. - only = set(get_column_name(column) for column in only) - # TODO In Python 2.7 or later, this should be a set literal. - only |= set(['type', 'id']) - if exclude is not None: - # Convert SQLAlchemy Column objects to strings if necessary. - # - # TODO In Python 2.7 or later, this should be a set comprehension. - exclude = set(get_column_name(column) for column in exclude) - self.default_fields = only - self.exclude = exclude - self.additional_attributes = additional_attributes - - def __call__(self, instance, only=None): - """Returns a dictionary representing the fields of the specified - instance of a SQLAlchemy model. - - The returned dictionary is suitable as an argument to - :func:`flask.jsonify`; datetime objects (:class:`datetime.date`, - :class:`datetime.time`, :class:`datetime.datetime`, and - :class:`datetime.timedelta`) as well as :class:`uuid.UUID` - objects are converted to string representations, so no special - JSON encoder behavior is required. - - If `only` is a list, only the fields and relationships whose - names appear as strings in `only` will appear in the resulting - dictionary. This filter is applied *after* the default fields - specified in the `only` keyword argument to the constructor of - this class, so only fields that appear in both `only` keyword - arguments will appear in the returned dictionary. The only - exception is that the keys ``'id'`` and ``'type'`` will always - appear, regardless of whether they appear in `only`. - - Since this function creates absolute URLs to resources linked to the - given instance, it must be called within a `Flask request context`_. - - .. _Flask request context: http://flask.pocoo.org/docs/0.10/reqcontext/ - - """ - # Always include at least the type, ID, and the self link, regardless - # of what the user requested. - if only is not None: - # TODO Should the 'self' link be mandatory as well? - # TODO In Python 2.7 or later, this should be a set literal. - only = set(only) | set(['type', 'id']) - model = type(instance) - try: - inspected_instance = inspect(model) - except NoInspectionAvailable: - return instance - column_attrs = inspected_instance.column_attrs.keys() - descriptors = inspected_instance.all_orm_descriptors.items() - # hybrid_columns = [k for k, d in descriptors - # if d.extension_type == hybrid.HYBRID_PROPERTY - # and not (deep and k in deep)] - hybrid_columns = [k for k, d in descriptors - if d.extension_type == HYBRID_PROPERTY] - columns = column_attrs + hybrid_columns - # Also include any attributes specified by the user. - if self.additional_attributes is not None: - columns += self.additional_attributes - - # Only include fields allowed by the user during the instantiation of - # this object. - if self.default_fields is not None: - columns = (c for c in columns if c in self.default_fields) - # If `only` is a list, only include those columns that are in the list. - if only is not None: - columns = (c for c in columns if c in only) - - # Exclude columns specified by the user during the instantiation of - # this object. - if self.exclude is not None: - columns = (c for c in columns if c not in self.exclude) - # Exclude column names that are blacklisted. - columns = (c for c in columns - if not c.startswith('__') and c not in COLUMN_BLACKLIST) - # Exclude column names that are foreign keys. - foreign_key_columns = foreign_keys(model) - columns = (c for c in columns if c not in foreign_key_columns) - - # Create a dictionary mapping attribute name to attribute value for - # this particular instance. - # - # TODO In Python 2.7 and later, this should be a dict comprehension. - attributes = dict((column, getattr(instance, column)) - for column in columns) - # Call any functions that appear in the result. - # - # TODO In Python 2.7 and later, this should be a dict comprehension. - attributes = dict((k, (v() if callable(v) else v)) - for k, v in attributes.items()) - # Serialize any date- or time-like objects that appear in the - # attributes. - # - # TODO In Flask 1.0, the default JSON encoder for the Flask - # application object does this automatically. Alternately, the - # user could have set a smart JSON encoder on the Flask - # application, which would cause these attributes to be - # converted to strings when the Response object is created (in - # the `jsonify` function, for example). However, we should not - # rely on that JSON encoder since the user could set any crazy - # encoder on the Flask application. - for key, val in attributes.items(): - if isinstance(val, (date, datetime, time)): - attributes[key] = val.isoformat() - elif isinstance(val, timedelta): - attributes[key] = total_seconds(val) - # Recursively serialize any object that appears in the - # 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 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(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) - # Create the result dictionary and add the attributes. - result = dict(id=id_, type=type_) - if attributes: - result['attributes'] = attributes - # Add the self link unless it has been explicitly excluded. - if ((self.default_fields is None or 'self' in self.default_fields) - and (only is None or 'self' in only)): - instance_id = primary_key_value(instance) - # `url_for` may raise a `BuildError` if the user has not created a - # GET API endpoint for this model. In this case, we simply don't - # provide a self link. - # - # TODO This might fail if the user has set the - # `current_app.build_error_handler` attribute, in which case, the - # exception may not be raised. - try: - path = url_for(model, instance_id, _method='GET') - except BuildError: - pass - else: - url = urljoin(request.url_root, path) - result['links'] = dict(self=url) - # # add any included methods - # if include_methods is not None: - # for method in include_methods: - # if '.' not in method: - # value = getattr(instance, method) - # # Allow properties and static attributes in - # # include_methods - # if callable(value): - # value = value() - # result[method] = value - - # If the primary key is not named "id", we'll duplicate the - # primary key under the "id" key. - pk_name = primary_key_for(model) - if pk_name != 'id': - result['id'] = result['attributes'][pk_name] - # TODO Same problem as above. - # - # In order to comply with the JSON API standard, primary keys must be - # returned to the client as strings, so we convert it here. - if 'id' in result: - try: - result['id'] = str(result['id']) - except UnicodeEncodeError: - result['id'] = url_quote_plus(result['id'].encode('utf-8')) - # If there are relations to convert to dictionary form, put them into a - # special `links` key as required by JSON API. - relations = get_relations(model) - if self.default_fields is not None: - relations = [r for r in relations if r in self.default_fields] - # Only consider those relations listed in `only`. - if only is not None: - relations = [r for r in relations if r in only] - # Exclude relations specified by the user during the instantiation of - # this object. - if self.exclude is not None: - relations = [r for r in relations if r not in self.exclude] - if not relations: - return result - # For the sake of brevity, rename this function. - cr = create_relationship - # TODO In Python 2.7 and later, this should be a dict comprehension. - result['relationships'] = dict((rel, cr(model, instance, rel)) - for rel in relations) - return result - - -class DefaultRelationshipSerializer(Serializer): - """A default implementation of a serializer for resource identifier - objects for use in relationship objects in JSON API documents. - - This serializer differs from the default serializer for resources - since it only provides an ``'id'`` and a ``'type'`` in the - dictionary returned by the :meth:`__call__` method. - - """ - - def __call__(self, instance, only=None, _type=None): - if _type is None: - _type = collection_name(get_model(instance)) - return {'id': str(primary_key_value(instance)), 'type': _type} - - -class DefaultDeserializer(Deserializer): - """A default implementation of a deserializer for SQLAlchemy models. - - When called, this object returns an instance of a SQLAlchemy model - with fields and relations specified by the provided dictionary. - - """ - - def __init__(self, session, model, allow_client_generated_ids=False, **kw): - super(DefaultDeserializer, self).__init__(session, model, **kw) - - #: Whether to allow client generated IDs. - self.allow_client_generated_ids = allow_client_generated_ids - - def __call__(self, document): - """Creates and returns an instance of the SQLAlchemy model - specified in the constructor. - - Everything in the `document` other than the `data` element is - ignored. - - For more information, see the documentation for the - :meth:`Deserializer.__call__` method. - - """ - if 'data' not in document: - raise MissingData - data = document['data'] - if 'type' not in data: - raise MissingType - if 'id' in data and not self.allow_client_generated_ids: - raise ClientGeneratedIDNotAllowed - type_ = data.pop('type') - expected_type = collection_name(self.model) - if type_ != expected_type: - raise ConflictingType(expected_type, type_) - # Check for any request parameter naming a column which does not exist - # on the current model. - for field in data: - if field == 'relationships': - for relation in data['relationships']: - if not has_field(self.model, relation): - raise UnknownRelationship(relation) - elif field == 'attributes': - for attribute in data['attributes']: - if not has_field(self.model, attribute): - raise UnknownAttribute(attribute) - # Determine which related instances need to be added. - links = {} - if 'relationships' in data: - links = data.pop('relationships', {}) - for link_name, link_object in links.items(): - if 'data' not in link_object: - raise MissingData(link_name) - linkage = link_object['data'] - related_model = get_related_model(self.model, link_name) - expected_type = collection_name(related_model) - # Create the deserializer for this relationship object. - DRD = DefaultRelationshipDeserializer - deserialize = DRD(self.session, related_model, link_name) - links[link_name] = deserialize(linkage) - # TODO Need to check here if any related instances are None, - # like we do in the patch() method. We could possibly refactor - # the code above and the code there into a helper function... - pass - # Move the attributes up to the top level. - data.update(data.pop('attributes', {})) - # Special case: if there are any dates, convert the string form of the - # date into an instance of the Python ``datetime`` object. - data = strings_to_datetimes(self.model, data) - # Create the new instance by keyword attributes. - instance = self.model(**data) - # Set each relation specified in the links. - for relation_name, related_value in links.items(): - setattr(instance, relation_name, related_value) - return instance - - -class DefaultRelationshipDeserializer(Deserializer): - """A default implementation of a deserializer for resource - identifier objects for use in relationships in JSON API documents. - - Each instance of this class should correspond to a particular - relationship of a model. - - This deserializer differs from the default deserializer for - resources since it expects that the input dictionary `data` to - :meth:`__call__` contains only ``'id'`` and ``'type'`` keys. - - `session` is the SQLAlchemy session in which to look for any related - resources. - - `model` is the SQLAlchemy model class of the relationship, *not the - primary resource*. With the related model class, this deserializer - will be able to use the ID provided to the :meth:`__call__` method - to determine the instance of the `related_model` class which is - being deserialized. - - `relation_name` is the name of the relationship being deserialized, - given as a string. This is used mainly for more helpful error - messages. - - """ - - def __init__(self, session, model, relation_name=None): - super(DefaultRelationshipDeserializer, self).__init__(session, model) - #: The related model whose objects this deserializer will return - #: in the :meth:`__call__` method. - self.model = model - - #: The collection name given to the related model. - self.type_name = collection_name(self.model) - - #: The name of the relationship being deserialized, as a string. - self.relation_name = relation_name - - def __call__(self, data): - """Gets the resource associated with the given resource - identifier object. - - `data` must be a dictionary containing exactly two elements, - ``'type'`` and ``'id'``, or a list of dictionaries of that - form. In the former case, the `data` represents a to-one - relation and in the latter a to-many relation. - - Returns the instance or instances of the SQLAlchemy model - specified in the constructor whose ID or IDs match the given - `data`. - - May raise :exc:`MissingID`, :exc:`MissingType`, or - :exc:`ConflictingType`. - - """ - # If this is a to-one relationship, get the sole instance of the model. - if not isinstance(data, list): - if 'id' not in data: - raise MissingID(self.relation_name) - if 'type' not in data: - raise MissingType(self.relation_name) - type_ = data['type'] - if type_ != self.type_name: - raise ConflictingType(self.relation_name, self.type_name, - type_) - id_ = data['id'] - return get_by(self.session, self.model, id_) - # Otherwise, if this is a to-many relationship, recurse on each - # and return a list of instances. - return list(map(self, data)) - - -#: Provides basic, uncustomized serialization functionality as provided by -#: :class:`DefaultSerializer`. -#: -#: This function is suitable for calling on its own, no other instantiation or -#: customization necessary. -simple_serialize = DefaultSerializer() - - -#: Basic serializer for relationship objects. -#: -#: This function is suitable for calling on its own, no other instantiation or -#: customization necessary. -simple_relationship_serialize = DefaultRelationshipSerializer() diff --git a/flask_restless/serialization/__init__.py b/flask_restless/serialization/__init__.py new file mode 100644 index 00000000..d851afdd --- /dev/null +++ b/flask_restless/serialization/__init__.py @@ -0,0 +1,23 @@ +# __init__.py - indicates that this directory is a Python package +# +# 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. +"""Serialization and deserialization for Flask-Restless.""" +from .deserializers import DefaultDeserializer +from .exceptions import DeserializationException +from .exceptions import MultipleExceptions +from .exceptions import SerializationException +from .serializers import DefaultSerializer +from .serializers import JsonApiDocument +from .serializers import simple_heterogeneous_serialize_many +from .serializers import simple_serialize +from .serializers import simple_serialize_many +from .serializers import simple_relationship_serialize +from .serializers import simple_relationship_serialize_many diff --git a/flask_restless/serialization/deserializers.py b/flask_restless/serialization/deserializers.py new file mode 100644 index 00000000..a653997e --- /dev/null +++ b/flask_restless/serialization/deserializers.py @@ -0,0 +1,344 @@ +# deserializers.py - SQLAlchemy deserializers for JSON documents +# +# 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. +"""Classes for deserialization of JSON API documents to SQLAlchemy. + +The abstract base class :class:`Deserializer` can be used to implement +custom deserialization from JSON API documents to SQLAlchemy +objects. The :class:`DefaultDeserializer` provide some basic +deserialization as expected by classes that follow the JSON API +protocol. + +The implementations here are closely coupled to the rest of the +Flask-Restless code. + +""" +from .exceptions import ClientGeneratedIDNotAllowed +from .exceptions import ConflictingType +from .exceptions import DeserializationException +from .exceptions import MissingData +from .exceptions import MissingID +from .exceptions import MissingType +from .exceptions import MultipleExceptions +from .exceptions import NotAList +from .exceptions import UnknownRelationship +from .exceptions import UnknownAttribute +from ..helpers import collection_name +from ..helpers import get_related_model +from ..helpers import get_by +from ..helpers import has_field +from ..helpers import is_like_list +from ..helpers import strings_to_datetimes + + +class Deserializer(object): + """An object that transforms a dictionary representation of a JSON + API document into an instance or instances of the SQLAlchemy model + specified at instantiation time. + + `session` is the SQLAlchemy session in which to look for any related + resources. + + `model` is the class of which instances will be created by the + :meth:`.deserialize` and :meth:`.deserialize_many` methods. + + **This is a base class with no implementation.** + + """ + + def __init__(self, session, model): + self.session = session + self.model = model + + def deserialize(self, document): + """Creates and returns a new instance of the SQLAlchemy model + specified in the constructor whose attributes are given by the + specified dictionary. + + `document` must be a dictionary representation of a JSON API + document containing a single resource as primary data, as + specified in the JSON API specification. For more information, + see the `Resource Objects`_ section of the JSON API + specification. + + **This method is not implemented in this base class; subclasses + must override this method.** + + .. _Resource Objects: http://jsonapi.org/format/#document-structure-resource-objects + + """ + raise NotImplementedError + + def deserialize_many(self, document): + """Creates and returns a list of instances of the SQLAlchemy + model specified in the constructor whose fields are given in the + JSON API document. + + `document` must be a dictionary representation of a JSON API + document containing a list of resources as primary data, as + specified in the JSON API specification. For more information, + see the `Resource Objects`_ section of the JSON API + specification. + + **This method is not implemented in this base class; subclasses + must override this method.** + + .. _Resource Objects: http://jsonapi.org/format/#document-structure-resource-objects + + """ + raise NotImplementedError + + +class DefaultDeserializer(Deserializer): + """A default implementation of a deserializer for SQLAlchemy models. + + When called, this object returns an instance of a SQLAlchemy model + with fields and relations specified by the provided dictionary. + + """ + + def __init__(self, session, model, allow_client_generated_ids=False, **kw): + super(DefaultDeserializer, self).__init__(session, model, **kw) + + #: Whether to allow client generated IDs. + self.allow_client_generated_ids = allow_client_generated_ids + + def _load(self, data): + """Returns a new instance of a SQLAlchemy model represented by + the given resource object. + + `data` is a dictionary representation of a JSON API resource + object. + + This method may raise one of various + :exc:`DeserializationException` subclasses. If the instance has + a to-many relationship, this method may raise + :exc:`MultipleExceptions` as well, if there are multiple + exceptions when deserializing the related instances. + + """ + if 'type' not in data: + raise MissingType + if 'id' in data and not self.allow_client_generated_ids: + raise ClientGeneratedIDNotAllowed + type_ = data.pop('type') + expected_type = collection_name(self.model) + if type_ != expected_type: + raise ConflictingType(expected_type, type_) + # Check for any request parameter naming a column which does not exist + # on the current model. + for field in data: + if field == 'relationships': + for relation in data['relationships']: + if not has_field(self.model, relation): + raise UnknownRelationship(relation) + elif field == 'attributes': + for attribute in data['attributes']: + if not has_field(self.model, attribute): + raise UnknownAttribute(attribute) + # Determine which related instances need to be added. + links = {} + if 'relationships' in data: + links = data.pop('relationships', {}) + for link_name, link_object in links.items(): + related_model = get_related_model(self.model, link_name) + DRD = DefaultRelationshipDeserializer + deserializer = DRD(self.session, related_model, link_name) + # Create the deserializer for this relationship object. + if is_like_list(self.model, link_name): + deserialize = deserializer.deserialize_many + else: + deserialize = deserializer.deserialize + # This may raise a DeserializationException or + # MultipleExceptions. + links[link_name] = deserialize(link_object) + # TODO Need to check here if any related instances are None, + # like we do in the patch() method. We could possibly refactor + # the code above and the code there into a helper function... + pass + # Move the attributes up to the top level. + data.update(data.pop('attributes', {})) + # Special case: if there are any dates, convert the string form of the + # date into an instance of the Python ``datetime`` object. + data = strings_to_datetimes(self.model, data) + # Create the new instance by keyword attributes. + instance = self.model(**data) + # Set each relation specified in the links. + for relation_name, related_value in links.items(): + setattr(instance, relation_name, related_value) + return instance + + def deserialize(self, document): + """Creates and returns an instance of the SQLAlchemy model + specified in the JSON API document. + + Everything in the `document` other than the `data` element is + ignored. + + For more information, see the documentation for the + :meth:`Deserializer.deserialize` method. + + """ + if 'data' not in document: + raise MissingData + data = document['data'] + return self._load(data) + + # # TODO JSON API currently doesn't support bulk creation of resources, + # # so this code cannot be accurately used/tested. + # def deserialize_many(self, document): + # """Creates and returns a list of instances of the SQLAlchemy + # model specified in the constructor whose fields are given in the + # JSON API document. + # + # This method assumes that each resource in the given document is + # of the same type. + # + # For more information, see the documentation for the + # :meth:`Deserializer.deserialize_many` method. + # + # """ + # if 'data' not in document: + # raise MissingData + # data = document['data'] + # if not isinstance(data, list): + # raise NotAList + # # Since loading each instance from a given resource object + # # representation could theoretically raise a + # # DeserializationException, we collect all the errors and wrap + # # them in a MultipleExceptions exception object. + # result = [] + # failed = [] + # for resource in data: + # try: + # instance = self._load(resource) + # result.append(instance) + # except DeserializationException as exception: + # failed.append(exception) + # if failed: + # raise MultipleExceptions(failed) + # return result + + +class DefaultRelationshipDeserializer(Deserializer): + """A default implementation of a deserializer for resource + identifier objects for use in relationships in JSON API documents. + + Each instance of this class should correspond to a particular + relationship of a model. + + This deserializer differs from the default deserializer for + resources since it expects that the ``'data'`` element of the input + dictionary to :meth:`.deserialize` contains only ``'id'`` and + ``'type'`` keys. + + `session` is the SQLAlchemy session in which to look for any related + resources. + + `model` is the SQLAlchemy model class of the relationship, *not the + primary resource*. With the related model class, this deserializer + will be able to use the ID provided to the :meth:`__call__` method + to determine the instance of the `related_model` class which is + being deserialized. + + `relation_name` is the name of the relationship being deserialized, + given as a string. This is used mainly for more helpful error + messages. + + """ + + def __init__(self, session, model, relation_name=None): + super(DefaultRelationshipDeserializer, self).__init__(session, model) + #: The related model whose objects this deserializer will return + #: in the :meth:`__call__` method. + self.model = model + + #: The collection name given to the related model. + self.type_name = collection_name(self.model) + + #: The name of the relationship being deserialized, as a string. + self.relation_name = relation_name + + def _load(self, data): + """Gets the resource associated with the given resource + identifier object. + + `data` must be a dictionary containing exactly two elements, + ``'type'`` and ``'id'``, or a list of dictionaries of that + form. In the former case, the `data` represents a to-one + relation and in the latter a to-many relation. + + Returns the instance or instances of the SQLAlchemy model + specified in the constructor whose ID or IDs match the given + `data`. + + May raise :exc:`MissingID`, :exc:`MissingType`, or + :exc:`ConflictingType`. + + """ + # If this is a to-one relationship, get the sole instance of the model. + if 'id' not in data: + raise MissingID(self.relation_name) + if 'type' not in data: + raise MissingType(self.relation_name) + type_ = data['type'] + if type_ != self.type_name: + raise ConflictingType(self.relation_name, self.type_name, + type_) + id_ = data['id'] + return get_by(self.session, self.model, id_) + + def deserialize(self, document): + """Returns the SQLAlchemy instance identified by the resource + identifier given as the primary data in the given document. + + The type given in the resource identifier must match the + collection name associated with the SQLAlchemy model specified + in the constructor of this class. If not, this raises + :exc:`ConflictingType`. + + """ + if 'data' not in document: + raise MissingData(self.relation_name) + resource_identifier = document['data'] + return self._load(resource_identifier) + + def deserialize_many(self, document): + """Returns a list of SQLAlchemy instances identified by the + resource identifiers given as the primary data in the given + document. + + The type given in each resource identifier must match the + collection name associated with the SQLAlchemy model specified + in the constructor of this class. If not, this raises + :exc:`ConflictingType`. + + """ + if 'data' not in document: + raise MissingData(self.relation_name) + resource_identifiers = document['data'] + if not isinstance(resource_identifiers, list): + raise NotAList(self.relation_name) + # Since loading each related instance from a given resource + # identifier object representation could theoretically raise a + # DeserializationException, we collect all the errors and wrap + # them in a MultipleExceptions exception object. + result = [] + failed = [] + for resource_identifier in resource_identifiers: + try: + instance = self._load(resource_identifier) + result.append(instance) + except DeserializationException as exception: + failed.append(exception) + if failed: + raise MultipleExceptions(failed) + return result diff --git a/flask_restless/serialization/exceptions.py b/flask_restless/serialization/exceptions.py new file mode 100644 index 00000000..fa7f4579 --- /dev/null +++ b/flask_restless/serialization/exceptions.py @@ -0,0 +1,243 @@ +# exceptions.py - serialization exceptions +# +# 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. +"""Exceptions that arise from serialization or deserialization.""" + + +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 + :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, message=None, resource=None, *args, **kw): + super(SerializationException, self).__init__(*args, **kw) + self.resource = resource + self.message = message + self.instance = instance + + +class MultipleExceptions(Exception): + """Raised when there are multiple problems in serialization or + deserialization. + + `exceptions` is a non-empty sequence of other exceptions that have + been raised in the code. + + You may wish to raise this exception when implementing the + :meth:`.Serializer.serialize_many` method, for example, if there are + multiple exceptions + + """ + + def __init__(self, exceptions, *args, **kw): + super(MultipleExceptions, self).__init__(*args, **kw) + + #: Sequence of other exceptions that have been raised in the code. + self.exceptions = exceptions + + +class DeserializationException(Exception): + """Raised when there is a problem deserializing a Python dictionary to an + instance of a SQLAlchemy model. + + `status` is an integer representing the HTTP status code that + corresponds to this error. If not specified, it is set to 400, + representing :http:status:`400`. + + `detail` is a string describing the problem in more detail. If + provided, this will be incorporated in the return value of + :meth:`.message`. + + Each of the keyword arguments `status` and `detail` are assigned + directly to instance-level attributes :attr:`status` and + :attr:`detail`. + + """ + + def __init__(self, status=400, detail=None, *args, **kw): + super(DeserializationException, self).__init__(*args, **kw) + + #: A string describing the problem in more detail. + self.detail = detail + + #: The HTTP status code corresponding to this error. + self.status = status + + def message(self): + """Returns a more detailed description of the problem as a + string. + + """ + base = 'Failed to deserialize object' + if self.detail is not None: + return '{0}: {1}'.format(base, self.detail) + return base + + +class NotAList(DeserializationException): + """Raised when a ``data`` element exists but is not a list when it + should be, as when deserializing a to-many relationship. + + """ + + def __init__(self, relation_name=None, *args, **kw): + # # For now, this is only raised when calling deserialize_many() + # # on a relationship, so this extra message should always be + # # inserted. + # if relation_name is not None: + inner = ('in linkage for relationship "{0}" ').format(relation_name) + # else: + # inner = '' + + detail = ('"data" element {0}must be a list when calling' + ' deserialize_many(); maybe you meant to call' + ' deserialize()?').format(inner) + + super(NotAList, self).__init__(detail=detail, *args, **kw) + + +class ClientGeneratedIDNotAllowed(DeserializationException): + """Raised when attempting to deserialize a resource that provides + an ID when an ID is not allowed. + + """ + + def __init__(self, *args, **kw): + detail = 'Server does not allow client-generated IDS' + sup = super(ClientGeneratedIDNotAllowed, self) + sup.__init__(status=403, detail=detail, *args, **kw) + + +class ConflictingType(DeserializationException): + """Raised when attempting to deserialize a linkage object with an + unexpected ``'type'`` key. + + `relation_name` is a string representing the name of the + relationship for which a linkage object has a conflicting type. + + `expected_type` is a string representing the expected type of the + related resource. + + `given_type` is is a string representing the given value of the + ``'type'`` element in the resource. + + """ + + def __init__(self, expected_type, given_type, relation_name=None, *args, + **kw): + if relation_name is None: + inner = '' + else: + inner = (' in linkage object for relationship' + ' "{0}"').format(relation_name) + detail = 'expected type "{0}" but got type "{1}"{2}' + detail = detail.format(expected_type, given_type, inner) + sup = super(ConflictingType, self) + sup.__init__(status=409, detail=detail, *args, **kw) + + +class UnknownField(DeserializationException): + """Raised when attempting to deserialize an object that references a + field that does not exist on the model. + + `field` is the name of the unknown field as a string. + + """ + + #: Whether the unknown field is given as a field or a relationship. + #: + #: This attribute can only take one of the two values ``'field'`` or + #: ``'relationship'``. + field_type = None + + def __init__(self, field, *args, **kw): + detail = 'model has no {0} "{1}"'.format(self.field_type, field) + super(UnknownField, self).__init__(detail=detail, *args, **kw) + + +class UnknownRelationship(UnknownField): + """Raised when attempting to deserialize a linkage object that + references a relationship that does not exist on the model. + + """ + field_type = 'relationship' + + +class UnknownAttribute(UnknownField): + """Raised when attempting to deserialize an object that specifies a + field that does not exist on the model. + + """ + field_type = 'attribute' + + +class MissingInformation(DeserializationException): + """Raised when a linkage object does not specify an element required by + the JSON API specification. + + `relation_name` is the name of the relationship in which the linkage + object is missing information. + + """ + + #: The name of the key in the dictionary that is missing. + #: + #: Subclasses must set this class attribute. + element = None + + def __init__(self, relation_name=None, *args, **kw): + #: The relationship in which a linkage object is missing information. + self.relation_name = relation_name + + if relation_name is not None: + inner = (' in linkage object for relationship' + ' "{0}"').format(relation_name) + else: + inner = '' + detail = 'missing "{0}" element{1}'.format(self.element, inner) + super(MissingInformation, self).__init__(detail=detail, *args, **kw) + + +class MissingData(MissingInformation): + """Raised when a resource does not specify a ``'data'`` element + where required by the JSON API specification. + + """ + element = 'data' + + +class MissingID(MissingInformation): + """Raised when a resource does not specify an ``'id'`` element where + required by the JSON API specification. + + """ + element = 'id' + + +class MissingType(MissingInformation): + """Raised when a resource does not specify a ``'type'`` element + where required by the JSON API specification. + + """ + element = 'type' diff --git a/flask_restless/serialization/serializers.py b/flask_restless/serialization/serializers.py new file mode 100644 index 00000000..9a9cec6a --- /dev/null +++ b/flask_restless/serialization/serializers.py @@ -0,0 +1,633 @@ +# serializers.py - JSON serializers for SQLAlchemy models +# +# 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. +"""Classes for JSON serialization of SQLAlchemy models. + +The abstract base class :class:`Serializer` can be used to implement +custom serialization from SQLAlchemy objects. The +:class:`DefaultSerializer` provide some basic serialization as expected +by classes that follow the JSON API protocol. + +The implementations here are closely coupled to the rest of the +Flask-Restless code. + +""" +from datetime import date +from datetime import datetime +from datetime import time +from datetime import timedelta +try: + from urllib.parse import urljoin +except ImportError: + from urlparse import urljoin + +from flask import request +from sqlalchemy.exc import NoInspectionAvailable +from sqlalchemy.ext.hybrid import HYBRID_PROPERTY +from sqlalchemy.inspection import inspect +from werkzeug.routing import BuildError +from werkzeug.urls import url_quote_plus + +from .exceptions import SerializationException +from .exceptions import MultipleExceptions +from ..helpers import collection_name +from ..helpers import foreign_keys +from ..helpers import get_model +from ..helpers import get_related_model +from ..helpers import get_relations +from ..helpers import is_like_list +from ..helpers import is_mapped_class +from ..helpers import primary_key_for +from ..helpers import primary_key_value +from ..helpers import serializer_for +from ..helpers import url_for + +#: Names of columns which should definitely not be considered user columns to +#: be included in a dictionary representation of a model. +COLUMN_BLACKLIST = ('_sa_polymorphic_on', ) + +#: The highest version of the JSON API specification supported by +#: Flask-Restless. +JSONAPI_VERSION = '1.0' + + +# TODO In Python 2.7 or later, we can just use `timedelta.total_seconds()`. +if hasattr(timedelta, 'total_seconds'): + def total_seconds(td): + return td.total_seconds() +else: + # This formula comes from the Python 2.7 documentation for the + # `timedelta.total_seconds` method. + def total_seconds(td): + secs = td.seconds + td.days * 24 * 3600 + return (td.microseconds + secs * 10**6) / 10**6 + + +def create_relationship(model, instance, relation): + """Creates a relationship from the given relation name. + + Returns a dictionary representing a relationship as described in + the `Relationships`_ section of the JSON API specification. + + `model` is the model class of the primary resource for which a + relationship object is being created. + + `instance` is the instance of the model for which we are considering + a related value. + + `relation` is the name of the relation of `instance` given as a + string. + + This function may raise :exc:`ValueError` if an API has not been + created for the primary model, `model`, or the model of the + relation. + + .. _Relationships: http://jsonapi.org/format/#document-resource-object-relationships + + """ + result = {} + # Create the self and related links. + pk_value = primary_key_value(instance) + self_link = url_for(model, pk_value, relation, relationship=True) + related_link = url_for(model, pk_value, relation) + result['links'] = {'self': self_link} + # If the user has not created a GET endpoint for the related + # resource, then there is no "related" link to provide, so we check + # whether the URL exists before setting the related link. + try: + related_model = get_related_model(model, relation) + url_for(related_model) + except ValueError: + pass + else: + result['links']['related'] = related_link + # Get the related value so we can see if it is a to-many + # relationship or a to-one relationship. + related_value = getattr(instance, relation) + # There are three possibilities for the relation: it could be a + # to-many relationship, a null to-one relationship, or a non-null + # to-one relationship. We decide whether the relation is to-many by + # determining whether it is list-like. + if is_like_list(instance, relation): + # We could pre-compute the "type" name for the related instances + # here and provide it in the `_type` keyword argument to the + # serialization function, but the to-many relationship could be + # heterogeneous. + result['data'] = list(map(simple_relationship_dump, related_value)) + elif related_value is not None: + result['data'] = simple_relationship_dump(related_value) + else: + result['data'] = None + return result + + +def JsonApiDocument(): + """A skeleton JSON API document, containing the basic elements but + no data. + + """ + document = { + 'data': None, + 'jsonapi': { + 'version': JSONAPI_VERSION + }, + 'links': {}, + 'meta': {}, + 'included': [] + } + return document + + +def get_column_name(column): + """Retrieve a column name from a column attribute of SQLAlchemy model + class, or a string. + + Raises `TypeError` when argument does not fall into either of those + options. + + """ + try: + inspected_column = inspect(column) + except NoInspectionAvailable: + # In this case, we assume the column is actually just a string. + return column + else: + return inspected_column.key + + +class Serializer(object): + """An object that serializes one or many instances of a SQLAlchemy + model to a dictionary representation. + + **This is a base class with no implementation.** + + """ + + def serialize(self, instance, only=None): + """Returns a dictionary representation of the specified instance + of a SQLAlchemy model. + + If `only` is a list, only the fields and relationships whose + names appear as strings in `only` should appear in the returned + dictionary. + + **This method is not implemented in this base class; subclasses must + override this method.** + + """ + raise NotImplementedError + + def serialize_many(self, instances, only=None): + """Returns a dictionary representation of the specified + instances of a SQLAlchemy model. + + If `only` is a list, only the fields and relationships whose + names appear as strings in `only` should appear in the returned + dictionary. + + **This method is not implemented in this base class; subclasses must + override this method.** + + """ + raise NotImplementedError + + +class DefaultSerializer(Serializer): + """A default implementation of a JSON API serializer for SQLAlchemy + models. + + The :meth:`.serialize` method of this class returns a complete JSON + API document as a dictionary containing the resource object + representation of the given instance of a SQLAlchemy model as its + primary data. Similarly, the :meth:`.serialize_many` method returns + a JSON API document containing a a list of resource objects as its + primary data. + + If `only` is a list, only these fields and relationships will in the + returned dictionary. The only exception is that the keys ``'id'`` + and ``'type'`` will always appear, regardless of whether they appear + in `only`. These settings take higher priority than the `only` list + provided to the :meth:`.serialize` or :meth:`.serialize_many` + methods: if an attribute or relationship appears in the `only` + argument to those method but not here in the constructor, it will + not appear in the returned dictionary. + + If `exclude` is a list, these fields and relationships will **not** + appear in the returned dictionary. + + If `additional_attributes` is a list, these attributes of the + instance to be serialized will appear in the returned + dictionary. This is useful if your model has an attribute that is + not a SQLAlchemy column but you want it to be exposed. + + You **must not** specify both `only` and `exclude` lists; if you do, + the behavior of this function is undefined. + + You **must not** specify a field in both `exclude` and in + `additional_attributes`; if you do, the behavior of this function is + undefined. + + """ + + def __init__(self, only=None, exclude=None, additional_attributes=None, + **kw): + super(DefaultSerializer, self).__init__(**kw) + # Always include at least the type and ID, regardless of what the user + # specified. + if only is not None: + # Convert SQLAlchemy Column objects to strings if necessary. + # + # TODO In Python 2.7 or later, this should be a set comprehension. + only = set(get_column_name(column) for column in only) + # TODO In Python 2.7 or later, this should be a set literal. + only |= set(['type', 'id']) + if exclude is not None: + # Convert SQLAlchemy Column objects to strings if necessary. + # + # TODO In Python 2.7 or later, this should be a set comprehension. + exclude = set(get_column_name(column) for column in exclude) + self.default_fields = only + self.exclude = exclude + self.additional_attributes = additional_attributes + + def _dump(self, instance, only=None): + # Always include at least the type and ID, regardless of what + # the user requested. + if only is not None: + # TODO In Python 2.7 or later, this should be a set literal. + only = set(only) | set(['type', 'id']) + model = type(instance) + try: + inspected_instance = inspect(model) + except NoInspectionAvailable: + message = 'failed to get columns for model {0}'.format(model) + raise SerializationException(instance, message=message) + column_attrs = inspected_instance.column_attrs.keys() + descriptors = inspected_instance.all_orm_descriptors.items() + # hybrid_columns = [k for k, d in descriptors + # if d.extension_type == hybrid.HYBRID_PROPERTY + # and not (deep and k in deep)] + hybrid_columns = [k for k, d in descriptors + if d.extension_type == HYBRID_PROPERTY] + columns = column_attrs + hybrid_columns + # Also include any attributes specified by the user. + if self.additional_attributes is not None: + columns += self.additional_attributes + + # Only include fields allowed by the user during the instantiation of + # this object. + if self.default_fields is not None: + columns = (c for c in columns if c in self.default_fields) + # If `only` is a list, only include those columns that are in the list. + if only is not None: + columns = (c for c in columns if c in only) + + # Exclude columns specified by the user during the instantiation of + # this object. + if self.exclude is not None: + columns = (c for c in columns if c not in self.exclude) + # Exclude column names that are blacklisted. + columns = (c for c in columns + if not c.startswith('__') and c not in COLUMN_BLACKLIST) + # Exclude column names that are foreign keys. + foreign_key_columns = foreign_keys(model) + columns = (c for c in columns if c not in foreign_key_columns) + + # Create a dictionary mapping attribute name to attribute value for + # this particular instance. + # + # TODO In Python 2.7 and later, this should be a dict comprehension. + attributes = dict((column, getattr(instance, column)) + for column in columns) + # Call any functions that appear in the result. + # + # TODO In Python 2.7 and later, this should be a dict comprehension. + attributes = dict((k, (v() if callable(v) else v)) + for k, v in attributes.items()) + # Serialize any date- or time-like objects that appear in the + # attributes. + # + # TODO In Flask 1.0, the default JSON encoder for the Flask + # application object does this automatically. Alternately, the + # user could have set a smart JSON encoder on the Flask + # application, which would cause these attributes to be + # converted to strings when the Response object is created (in + # the `jsonify` function, for example). However, we should not + # rely on that JSON encoder since the user could set any crazy + # encoder on the Flask application. + for key, val in attributes.items(): + if isinstance(val, (date, datetime, time)): + attributes[key] = val.isoformat() + elif isinstance(val, timedelta): + attributes[key] = total_seconds(val) + # Recursively serialize any object that appears in the + # 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 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(val)): + model_ = get_model(val) + try: + serializer = serializer_for(model_) + serialized_val = serializer.serialize(val) + except ValueError: + # TODO Should this cause an exception, or fail + # silently? See similar comments in `views/base.py`. + # # raise SerializationException(instance) + serialized_val = simple_serialize(val) + # We only need the data from the JSON API document, not + # the metadata. (So really the serializer is doing more + # work than it needs to here.) + attributes[key] = serialized_val['data'] + # Get the ID and type of the resource. + id_ = attributes.pop('id') + type_ = collection_name(model) + # Create the result dictionary and add the attributes. + result = dict(id=id_, type=type_) + if attributes: + result['attributes'] = attributes + # Add the self link unless it has been explicitly excluded. + is_self_in_default = (self.default_fields is None or + 'self' in self.default_fields) + is_self_in_only = only is None or 'self' in only + if is_self_in_default and is_self_in_only: + instance_id = primary_key_value(instance) + # `url_for` may raise a `BuildError` if the user has not created a + # GET API endpoint for this model. In this case, we simply don't + # provide a self link. + # + # TODO This might fail if the user has set the + # `current_app.build_error_handler` attribute, in which case, the + # exception may not be raised. + try: + path = url_for(model, instance_id, _method='GET') + except BuildError: + pass + else: + url = urljoin(request.url_root, path) + result['links'] = dict(self=url) + # # add any included methods + # if include_methods is not None: + # for method in include_methods: + # if '.' not in method: + # value = getattr(instance, method) + # # Allow properties and static attributes in + # # include_methods + # if callable(value): + # value = value() + # result[method] = value + + # If the primary key is not named "id", we'll duplicate the + # primary key under the "id" key. + pk_name = primary_key_for(model) + if pk_name != 'id': + result['id'] = result['attributes'][pk_name] + # TODO Same problem as above. + # + # In order to comply with the JSON API standard, primary keys must be + # returned to the client as strings, so we convert it here. + if 'id' in result: + try: + result['id'] = str(result['id']) + except UnicodeEncodeError: + result['id'] = url_quote_plus(result['id'].encode('utf-8')) + # If there are relations to convert to dictionary form, put them into a + # special `links` key as required by JSON API. + relations = get_relations(model) + if self.default_fields is not None: + relations = [r for r in relations if r in self.default_fields] + # Only consider those relations listed in `only`. + if only is not None: + relations = [r for r in relations if r in only] + # Exclude relations specified by the user during the instantiation of + # this object. + if self.exclude is not None: + relations = [r for r in relations if r not in self.exclude] + if not relations: + return result + # For the sake of brevity, rename this function. + cr = create_relationship + # TODO In Python 2.7 and later, this should be a dict comprehension. + result['relationships'] = dict((rel, cr(model, instance, rel)) + for rel in relations) + return result + + def serialize(self, instance, only=None): + """Returns a complete JSON API document as a dictionary + containing the resource object representation of the given + instance of a SQLAlchemy model as its primary data. + + The returned dictionary is suitable as an argument to + :func:`flask.jsonify`. Specifically, date and time objects + (:class:`datetime.date`, :class:`datetime.time`, + :class:`datetime.datetime`, and :class:`datetime.timedelta`) as + well as :class:`uuid.UUID` objects are converted to string + representations, so no special JSON encoder behavior is + required. + + If `only` is a list, only the fields and relationships whose + names appear as strings in `only` will appear in the resulting + dictionary. This filter is applied *after* the default fields + specified in the `only` keyword argument to the constructor of + this class, so only fields that appear in both `only` keyword + arguments will appear in the returned dictionary. The only + exception is that the keys ``'id'`` and ``'type'`` will always + appear, regardless of whether they appear in `only`. + + Since this method creates absolute URLs to resources linked to + the given instance, it must be called within a `Flask request + context`_. + + .. _Flask request context: http://flask.pocoo.org/docs/0.10/reqcontext/ + + """ + resource = self._dump(instance, only=only) + result = JsonApiDocument() + result['data'] = resource + return result + + def serialize_many(self, instances, only=None): + # Here we are assuming the iterable of instances is homogeneous + # (i.e. each instance is of the same type). + # + # Since loading each instance from a given resource object + # representation could theoretically raise a + # DeserializationException, we collect all the errors and wrap + # them in a MultipleExceptions exception object. + resources = [] + failed = [] + for instance in instances: + try: + resource = self._dump(instance, only=only) + resources.append(resource) + except SerializationException as exception: + failed.append(exception) + if failed: + raise MultipleExceptions(failed) + result = JsonApiDocument() + result['data'] = resources + return result + + +class HeterogeneousSerializer(DefaultSerializer): + """A serializer for heterogeneous collections of instances (that is, + collections in which each instance is of a different type). + + This class overrides the :meth:`DefaultSerializer.serialize_many` + method, which currently works only for homogeneous collections, to + apply the correct model-specific serializer for each instance (as + registered at the time of invoking :meth:`APIManager.create_api`). + + """ + + def serialize_many(self, instances, only=None): + """Serializes each instance using its model-specific serializer. + + The `only` keyword argument must be a dictionary mapping + resource type name to list of fields representing a sparse + fieldset. The values in this dictionary must be valid values for + the `only` keyword argument in the + :meth:`DefaultSerializer.serialize` method. + + """ + result = [] + failed = [] + for instance in instances: + # Determine the serializer for this instance. + model = get_model(instance) + try: + serializer = serializer_for(model) + except ValueError: + message = 'Failed to find serializer class' + exception = SerializationException(instance, message=message) + failed.append(exception) + continue + # This may also raise ValueError + try: + _type = collection_name(model) + except ValueError: + message = 'Failed to find collection name' + exception = SerializationException(instance, message=message) + failed.append(exception) + continue + _only = only.get(_type) + try: + serialized = serializer.serialize(instance, only=_only) + # We only need the data from the JSON API document, not + # the metadata. (So really the serializer is doing more + # work than it needs to here.) + # + # TODO We could use `serializer._dump` instead. + serialized = serialized['data'] + result.append(serialized) + except SerializationException as exception: + failed.append(exception) + if failed: + raise MultipleExceptions(failed) + return result + + +class DefaultRelationshipSerializer(Serializer): + """A default implementation of a serializer for resource identifier + objects for use in relationship objects in JSON API documents. + + This serializer differs from the default serializer for resources + since it only provides an ``'id'`` and a ``'type'`` in the + dictionary returned by the :meth:`.serialize` and + :meth:`.serialize_many` methods. + + """ + + def _dump(self, instance, _type=None): + if _type is None: + _type = collection_name(get_model(instance)) + id_ = primary_key_value(instance, as_string=True) + return {'id': id_, 'type': _type} + + def serialize(self, instance, only=None, _type=None): + resource_identifier = self._dump(instance, _type=_type) + result = JsonApiDocument() + result['data'] = resource_identifier + return result + + def serialize_many(self, instances, only=None, _type=None): + # Since dumping each resource identifier from a given instance + # could theoretically raise a SerializationException, we collect + # all the errors and wrap them in a MultipleExceptions exception + # object. + resource_identifiers = [] + failed = [] + for instance in instances: + try: + resource_identifier = self._dump(instance, _type=_type) + resource_identifiers.append(resource_identifier) + except SerializationException as exception: + failed.append(exception) + if failed: + raise MultipleExceptions(failed) + result = JsonApiDocument() + result['data'] = resource_identifiers + return result + + +#: This is an instance of the default serializer class, +#: :class:`DefaultSerializer`. +#: +#: The purpose of this instance is to provide easy access to default +#: serialization methods. +singleton_serializer = DefaultSerializer() + +singleton_heterogeneous_serializer = HeterogeneousSerializer() + +#: This is an instance of the default relationship serializer class, +#: :class:`DefaultRelationshipSerializer`. +#: +#: The purpose of this instance is to provide easy access to default +#: serialization methods. +singleton_relationship_serializer = DefaultRelationshipSerializer() + +simple_dump = singleton_serializer.serialize + +#: Provides basic, uncustomized serialization functionality as provided +#: by the :meth:`DefaultSerializer.serialize` method. +#: +#: This function is suitable for calling on its own, no other +#: instantiation or customization necessary. +simple_serialize = singleton_serializer.serialize + +#: Provides basic, uncustomized serialization functionality as provided +#: by the :meth:`DefaultSerializer.serialize_many` method. +#: +#: This function is suitable for calling on its own, no other +#: instantiation or customization necessary. +simple_serialize_many = singleton_serializer.serialize_many + +simple_heterogeneous_serialize_many = singleton_heterogeneous_serializer.serialize_many + +simple_relationship_dump = singleton_relationship_serializer._dump + +#: Provides basic, uncustomized serialization functionality as provided +#: by the :meth:`DefaultRelationshipSerializer.serialize` method. +#: +#: This function is suitable for calling on its own, no other +#: instantiation or customization necessary. +simple_relationship_serialize = singleton_relationship_serializer.serialize + +#: Provides basic, uncustomized serialization functionality as provided +#: by the :meth:`DefaultRelationshipSerializer.serialize_many` method. +#: +#: This function is suitable for calling on its own, no other +#: instantiation or customization necessary. +simple_relationship_serialize_many = \ + singleton_relationship_serializer.serialize_many diff --git a/flask_restless/views/base.py b/flask_restless/views/base.py index 8ebc0b22..20919b25 100644 --- a/flask_restless/views/base.py +++ b/flask_restless/views/base.py @@ -50,6 +50,7 @@ from ..helpers import collection_name from ..helpers import get_model +from ..helpers import get_related_model from ..helpers import is_like_list from ..helpers import primary_key_for from ..helpers import primary_key_value @@ -59,8 +60,12 @@ from ..search import search from ..search import search_relationship from ..search import UnknownField -from ..serialization import simple_relationship_serialize from ..serialization import DeserializationException +from ..serialization import JsonApiDocument +from ..serialization import MultipleExceptions +from ..serialization import simple_heterogeneous_serialize_many +from ..serialization import simple_relationship_serialize +from ..serialization import simple_relationship_serialize_many from ..serialization import SerializationException from .helpers import count from .helpers import upper_keys as upper @@ -198,21 +203,6 @@ def __init__(self, id_=None, links=None, status=400, code=None, title=None, self.meta = meta -class MultipleExceptions(Exception): - """Raised when there are multple problems in the code. - - `exceptions` is a non-empty sequence of other exceptions that have - been raised in the code. - - """ - - def __init__(self, exceptions, *args, **kw): - super(MultipleExceptions, self).__init__(*args, **kw) - - #: Sequence of other exceptions that have been raised in the code. - self.exceptions = exceptions - - def _is_msie8or9(): """Returns ``True`` if and only if the user agent of the client making the request indicates that it is Microsoft Internet Explorer 8 or 9. @@ -752,6 +742,7 @@ def errors_response(status, errors): .. _Errors: http://jsonapi.org/format/#errors """ + # TODO Use an error serializer. document = {'errors': errors, 'jsonapi': {'version': JSONAPI_VERSION}, 'meta': {_STATUS: status}} return document, status @@ -878,6 +869,77 @@ def collection_parameters(): mimerender = FlaskMimeRender()(default='jsonapi', jsonapi=jsonpify) +# TODO Subclasses for different kinds of linkers (relationship, resource +# object, to-one relations, related resource, etc.). +class Linker(object): + + def __init__(self, model): + self.model = model + + def _related_resource_links(self, resource, primary_resource, + relation_name): + resource_id = primary_key_value(primary_resource) + related_resource_id = primary_key_value(resource) + self_link = url_for(self.model, resource_id, relation_name, + related_resource_id) + links = {'self': self_link} + return links + + def _relationship_links(self, resource_id, relation_name): + self_link = url_for(self.model, resource_id, relation_name, + relationship=True) + related_link = url_for(self.model, resource_id, relation_name) + links = {'self': self_link, 'related': related_link} + return links + + def _to_one_relation_links(self, resource_id, relation_name): + self_link = url_for(self.model, resource_id, relation_name) + links = {'self': self_link} + return links + + def _primary_resource_links(self, resource_id): + self_link = url_for(self.model, resource_id=resource_id) + links = {'self': self_link} + return links + + def _collection_links(self): + self_link = url_for(self.model) + links = {'self': self_link} + return links + + def generate_links(self, resource, primary_resource, relation_name, + is_related_resource, is_relationship): + if primary_resource is not None: + if is_related_resource: + return self._related_resource_links(resource, primary_resource, + relation_name) + else: + resource_id = primary_key_value(primary_resource) + if is_relationship: + return self._relationship_links(resource_id, relation_name) + else: + return self._to_one_relation_links(resource_id, + relation_name) + else: + if resource is not None: + resource_id = primary_key_value(resource) + return self._primary_resource_links(resource_id) + else: + return self._collection_links() + + +class PaginationLinker(object): + + def __init__(self, pagination): + self.pagination = pagination + + def generate_links(self): + return self.pagination.pagination_links + + def generate_header_links(self): + return self.pagination.header_links + + class Paginated(object): """Represents a paginated list of resources. @@ -1165,9 +1227,18 @@ class APIBase(ModelView): `primary_key` is as described in :ref:`primarykey`. + `serializer` and `deserializer` are as described in + :ref:`serialization`. + `validation_exceptions` are as described in :ref:`validation`. - `allow_to_many_replacement` is as described in :ref:`allowreplacement`. + `includes` are as described in :ref:`includes`. + + `page_size` and `max_page_size` are as described in + :ref:`pagination`. + + `allow_to_many_replacement` is as described in + :ref:`allowreplacement`. """ @@ -1214,17 +1285,17 @@ def __init__(self, session, model, preprocessors=None, postprocessors=None, #: #: This should not be ``None``, unless a subclass is not going to use #: serialization. - self.serialize = serializer + self.serializer = serializer #: A custom serialization function for linkage objects. - self.serialize_relationship = simple_relationship_serialize + #self.serialize_relationship = simple_relationship_serialize #: A custom deserialization function for primary resources; see #: :ref:`serialization` for more information. #: #: This should not be ``None``, unless a subclass is not going to use #: deserialization. - self.deserialize = deserializer + self.deserializer = deserializer #: The tuple of exceptions that are expected to be raised during #: validation when creating or updating a model. @@ -1309,56 +1380,6 @@ def _handle_validation_exception(self, exception): current_app.logger.exception(str(exception)) return errors_response(400, errors) - 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 @@ -1384,13 +1405,13 @@ def get_all_inclusions(self, instance_or_instances): # 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)) + instances = instance_or_instances + to_include = set(chain(map(self.resources_to_include, instances))) else: - to_include = self.resources_to_include(instance_or_instances) - # This may raise MultipleExceptions if there are problems - # serializing the included resources. - return self._serialize_many(to_include) + instance = instance_or_instances + to_include = self.resources_to_include(instance) + only = self.sparse_fields + return simple_heterogeneous_serialize_many(to_include, only=only) def _paginated(self, items, filters=None, sort=None, group_by=None): """Returns a :class:`Paginated` object representing the @@ -1409,9 +1430,9 @@ def _paginated(self, items, filters=None, sort=None, group_by=None): will be serialized as linkage objects instead of resources objects. - This method serializes the (correct page of) resources. As such, - it raises an instance of :exc:`MultipleExceptions` if there is a - problem serializing resources. + # This method serializes the (correct page of) resources. As such, + # it raises an instance of :exc:`MultipleExceptions` if there is a + # problem serializing resources. """ # Determine the client's page size request. Raise an exception @@ -1424,15 +1445,26 @@ 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: - 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(result) - return Paginated(result, page_size=page_size, - num_results=num_results) + # # These serialization calls may raise MultipleExceptions, or + # # possible SerializationExceptions. + # if is_relationship: + # result = self.relationship_serializer.serialize_many(items) + # else: + # serialize_many = self.serializer.serialize_many + # result = serialize_many(items, only=self.sparse_fields) + + # TODO Ideally we would like to use the folowing code. + # + # # Use `len()` here instead of doing `count(self.session, + # # items)` because the former should be faster. + # num_results = len(result['data']) + # + # but we can't get the length of the list of items until + # we serialize them. + num_results = count(self.session, items) + return Paginated(items, page_size=0, num_results=num_results) # Determine the client's page number request. Raise an exception # if the page number is out of bounds. page_number = int(request.args.get(PAGE_NUMBER_PARAM, 1)) @@ -1470,13 +1502,10 @@ 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) - # 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, + return Paginated(items, num_results=num_results, first=first, last=last, next_=next_, prev=prev, page_size=page_size, filters=filters, sort=sort, group_by=group_by) @@ -1488,45 +1517,54 @@ def _get_resource_helper(self, resource, primary_resource=None, # 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 + result = JsonApiDocument() + # Otherwise, we are serializing one of several possibilities. + # + # - a primary resource (as in `GET /person/1`), + # - a to-one relation (as in `GET /article/1/author`) + # - a related resource (as in `GET /person/1/articles/2`). + # - a to-one relationship (as in `GET /article/1/relationships/author`) + # else: - # 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._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} + # This covers the relationship object case... + if is_relationship: + result = simple_relationship_serialize(resource) + # ...and this covers the resource object cases. + else: + model = get_model(resource) + # 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: + serializer = 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. + serializer = self.serializer + # This may raise ValueError + _type = collection_name(model) + only = self.sparse_fields.get(_type) + # This may raise SerializationException + result = serializer.serialize(resource, only=only) + except SerializationException as exception: + return errors_from_serialization_exceptions([exception]) + # Determine the top-level links. - is_relation = primary_resource is not None - is_related_resource = is_relation and related_resource - if is_related_resource: - resource_id = primary_key_value(primary_resource) - related_resource_id = primary_key_value(resource) - # `self.model` should match `get_model(primary_resource)` - self_link = url_for(self.model, resource_id, relation_name, - related_resource_id) - result['links']['self'] = self_link - elif is_relation: - resource_id = primary_key_value(primary_resource) - # `self.model` should match `get_model(primary_resource)` - 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) - result['links']['self'] = self_link - result['links']['related'] = related_link - else: - self_link = url_for(self.model, resource_id, relation_name) - result['links']['self'] = self_link - else: - result['links']['self'] = url_for(self.model) + linker = Linker(self.model) + links = linker.generate_links(resource, primary_resource, + relation_name, related_resource, + is_relationship) + result['links'] = links + + # TODO Create an Includer class, like the Linker class. + # # Determine the top-level inclusions. + # includer = Includer(resource) + # includes = includer.generate_includes(resource) + # result['includes'] = includes # Include any requested resources in a compound document. try: @@ -1541,11 +1579,13 @@ def _get_resource_helper(self, resource, primary_resource=None, if included: result['included'] = included # HACK Need to do this here to avoid a too-long line. + is_relation = primary_resource is not None + is_related_resource = is_relation and related_resource 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. + # relation. We need to use the appropriate postprocessor here. processor_type = 'GET_{0}'.format(self.resource_processor_type(**kw)) for postprocessor in self.postprocessors[processor_type]: postprocessor(result=result) @@ -1578,11 +1618,7 @@ def _get_collection_helper(self, resource=None, relation_name=None, detail = 'Unable to construct query' return error_response(400, cause=exception, detail=detail) - # Prepare the dictionary that will contain the JSON API response. - result = {'links': {'self': url_for(self.model)}, - 'jsonapi': {'version': JSONAPI_VERSION}, - 'meta': {}} - + is_relationship = self.use_resource_identifiers() # Add the primary data (and any necessary links) to the JSON API # response object. # @@ -1592,44 +1628,110 @@ def _get_collection_helper(self, resource=None, relation_name=None, try: paginated = self._paginated(search_items, filters=filters, 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) - # Wrap the resulting object or list of objects under a `data` key. - result['data'] = paginated.items - # Provide top-level links. - result['links'].update(paginated.pagination_links) - link_header = ','.join(paginated.header_links) + # Serialize the found items. + # + # We are serializing one of three possibilities. + # + # - a collection of primary resources (as in `GET /person`), + # - a to-many relation (as in `GET /person/1/articles`), + # - a to-many relationship (as in `GET /person/1/relationships/articles`) + # + items = paginated.items + # This covers the relationship object case... + if is_relationship: + result = simple_relationship_serialize_many(items) + # ...and this covers the primary resource collection and + # to-many relation cases. + else: + # TODO This doesn't handle the case of a + # heterogeneous to-many relationship, in which case + # each related resource could be an instance of a + # distinct model class. + if is_relation: + primary_model = get_model(resource) + model = get_related_model(primary_model, relation_name) + else: + model = self.model + # 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: + serializer = 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. + serializer = self.serializer + # This may raise ValueError + _type = collection_name(model) + only = self.sparse_fields.get(_type) + try: + result = serializer.serialize_many(items, only=only) + except MultipleExceptions as e: + return errors_from_serialization_exceptions(e.exceptions) + except SerializationException as exception: + return errors_from_serialization_exceptions([exception]) + + # Determine the top-level links. + linker = Linker(self.model) + links = linker.generate_links(resource, None, relation_name, None, + is_relationship) + pagination_linker = PaginationLinker(paginated) + pagination_links = pagination_linker.generate_links() + if 'links' not in result: + result['links'] = {} + result['links'].update(links) + result['links'].update(pagination_links) + + # Create the metadata for the response, like headers and + # total number of found items. + pagination_header_links = pagination_linker.generate_header_links() + link_header = ','.join(pagination_header_links) headers = dict(Link=link_header) num_results = paginated.num_results + # Otherwise, the result of the search should be a single resource. else: try: - data = search_items.one() + resource = search_items.one() except NoResultFound as exception: detail = 'No result found' return error_response(404, cause=exception, detail=detail) 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. + # Serialize the single resource. try: - if self.use_resource_identifiers(): - serialize = self.serialize_relationship + if is_relationship: + result = simple_relationship_serialize(resource) else: - serialize = self.serialize - result['data'] = serialize(data, only=only) + only = self.sparse_fields.get(self.collection_name) + result = self.serializer.serialize(resource, only=only) except SerializationException as exception: return errors_from_serialization_exceptions([exception]) - primary_key = primary_key_for(data) + + # Determine the top-level links. + linker = Linker(self.model) + # Here we determine whether we are looking at a collection, + # as in `GET /people`, or a to-many relation, as in `GET + # /people/1/comments`. + if resource is None: + links = linker.generate_links(None, None, None, None, False, False) + else: + links = linker.generate_links(resource, None, None, False, False) + result['links'].update(links) + + # Create the metadata for the response, like headers and + # total number of found items. + primary_key = primary_key_for(resource) pk_value = result['data'][primary_key] - # The URL at which a client can access the instance matching this - # search query. - url = '{0}/{1}'.format(request.base_url, pk_value) - headers = dict(Location=url) + location = url_for(self.model, resource_id=pk_value) + headers = dict(Location=location) num_results = 1 # Determine the resources to include (in a compound document). @@ -1647,8 +1749,9 @@ def _get_collection_helper(self, resource=None, relation_name=None, # `errors_from_serialization_exception()`. return errors_from_serialization_exceptions(e.exceptions, included=True) - if included: - result['included'] = included + if 'included' not in result: + result['included'] = [] + result['included'].extend(included) # This method could have been called on either a request to # fetch a collection of resources or a to-many relation. @@ -1665,9 +1768,10 @@ def _get_collection_helper(self, resource=None, relation_name=None, # for more information. They don't really need to be under the ``meta`` # key, that's just for semantic consistency. status = 200 - result['meta'][_HEADERS] = headers - result['meta'][_STATUS] = status - result['meta']['total'] = num_results + meta = {_HEADERS: headers, _STATUS: status, 'total': num_results} + if 'meta' not in result: + result['meta'] = {} + result['meta'].update(meta) return result, status, headers def resources_to_include(self, instance): diff --git a/flask_restless/views/resources.py b/flask_restless/views/resources.py index df7c1a0e..812c59b8 100644 --- a/flask_restless/views/resources.py +++ b/flask_restless/views/resources.py @@ -27,8 +27,6 @@ from ..helpers import is_like_list from ..helpers import primary_key_value from ..helpers import strings_to_datetimes -from ..serialization import ClientGeneratedIDNotAllowed -from ..serialization import ConflictingType from ..serialization import DeserializationException from ..serialization import SerializationException from .base import APIBase @@ -37,12 +35,38 @@ from .base import error_response from .base import errors_from_serialization_exceptions from .base import errors_response -from .base import JSONAPI_VERSION from .base import MultipleExceptions from .base import SingleKeyError from .helpers import changes_on_update +def errors_from_deserialization_exceptions(exceptions, included=False): + """Returns an errors response object, as returned by + :func:`errors_response`, representing the given list of + :exc:`DeserializationException` 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. + + """ + + def _to_error(exception): + detail = exception.message() + status = exception.status + return error(status=status, detail=detail) + + errors = list(map(_to_error, exceptions)) + # Workaround: if there is only one error, assign the status code of + # that error object to be the status code of the actual HTTP + # response. + if len(errors) == 1: + status = errors[0]['status'] + else: + status = 400 + return errors_response(status, errors) + + class API(APIBase): """Provides method-based dispatching for :http:method:`get`, :http:method:`post`, :http:method:`patch`, and :http:method:`delete` @@ -373,38 +397,34 @@ def post(self): """ # try to read the parameters for the model from the body of the request try: - data = json.loads(request.get_data()) or {} + document = json.loads(request.get_data()) or {} except (BadRequest, TypeError, ValueError, OverflowError) as exception: detail = 'Unable to decode data' return error_response(400, cause=exception, detail=detail) # apply any preprocessors to the POST arguments for preprocessor in self.preprocessors['POST_RESOURCE']: - preprocessor(data=data) + preprocessor(data=document) # Convert the dictionary representation into an instance of the # model. try: - instance = self.deserialize(data) + instance = self.deserializer.deserialize(document) self.session.add(instance) self.session.commit() - except ClientGeneratedIDNotAllowed as exception: - detail = exception.message() - return error_response(403, cause=exception, detail=detail) - except ConflictingType as exception: - detail = exception.message() - return error_response(409, cause=exception, detail=detail) + # This also catches subclasses of `DeserializationException`, + # like ClientGeneratedIDNotAllowed and ConflictingType. except DeserializationException as exception: - detail = exception.message() - return error_response(400, cause=exception, detail=detail) + return errors_from_deserialization_exceptions([exception]) + except MultipleExceptions as e: + return errors_from_deserialization_exceptions(e.exceptions) except self.validation_exceptions as exception: return self._handle_validation_exception(exception) - fields_for_this = self.sparse_fields.get(self.collection_name) + only = self.sparse_fields.get(self.collection_name) # Get the dictionary representation of the new instance as it # appears in the database. try: - data = self.serialize(instance, only=fields_for_this) + result = self.serializer.serialize(instance, only=only) except SerializationException as exception: - detail = 'Failed to serialize object' - return error_response(400, cause=exception, detail=detail) + return errors_from_serialization_exceptions([exception]) # Determine the value of the primary key for this instance and # encode URL-encode it (in case it is a Unicode string). primary_key = primary_key_value(instance, as_string=True) @@ -413,8 +433,6 @@ def post(self): url = '{0}/{1}'.format(request.base_url, primary_key) # Provide that URL in the Location header in the response. headers = dict(Location=url) - # Wrap the resulting object or list of objects under a 'data' key. - result = {'jsonapi': {'version': JSONAPI_VERSION}, 'data': data} # Include any requested resources in a compound document. try: included = self.get_all_inclusions(instance) @@ -426,7 +444,7 @@ def post(self): return errors_from_serialization_exceptions(e.exceptions, included=True) if included: - result['included'] = included + result['included'].extend(included) status = 201 for postprocessor in self.postprocessors['POST_RESOURCE']: postprocessor(result=result) @@ -615,7 +633,11 @@ def patch(self, resource_id): # updates specified by the request, we must return 200 OK and a # representation of the modified resource. if self.changes_on_update: - result = dict(data=self.serialize(instance)) + only = self.sparse_fields.get(self.collection_name) + try: + result = self.serializer.serialize(instance, only=only) + except SerializationException as exception: + return errors_from_serialization_exceptions([exception]) status = 200 else: result = dict() diff --git a/setup.py b/setup.py index d307ac6d..09783645 100644 --- a/setup.py +++ b/setup.py @@ -91,7 +91,8 @@ def find_version(*file_path): 'Topic :: Internet :: WWW/HTTP', 'Topic :: Software Development :: Libraries :: Python Modules' ], - description='A Flask extension for easy ReSTful API generation', + description=('Flask extension for generating a JSON API interface for' + ' SQLAlchemy models'), download_url='https://pypi.python.org/pypi/Flask-Restless', install_requires=REQUIREMENTS, include_package_data=True, diff --git a/tests/helpers.py b/tests/helpers.py index cfb8aedd..e220e62d 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -43,6 +43,10 @@ from flask.ext.restless import APIManager from flask.ext.restless import CONTENT_TYPE +from flask.ext.restless import DefaultSerializer +from flask.ext.restless import DefaultDeserializer +from flask.ext.restless import DeserializationException +from flask.ext.restless import SerializationException dumps = json.dumps loads = json.loads @@ -64,6 +68,47 @@ CLASS_TYPES = (types.TypeType, types.ClassType) if IS_PYTHON2 else (type, ) +class raise_s_exception(DefaultSerializer): + """A serializer that unconditionally raises an exception when + either :meth:`.serialize` or :meth:`.serialize_many` is called. + + This class is useful for tests of serialization exceptions. + + """ + + def serialize(self, instance, *args, **kw): + """Immediately raises a :exc:`SerializationException` with + access to the provided `instance` of a SQLAlchemy model. + + """ + raise SerializationException(instance) + + def serialize_many(self, instances, *args, **kw): + """Immediately raises a :exc:`SerializationException`. + + This function requires `instances` to be non-empty. + + """ + raise SerializationException(instances[0]) + + +class raise_d_exception(DefaultDeserializer): + """A deserializer that unconditionally raises an exception when + either :meth:`.deserialize` or :meth:`.deserialize_many` is called. + + This class is useful for tests of deserialization exceptions. + + """ + + def deserialize(self, *args, **kw): + """Immediately raises a :exc:`DeserializationException`.""" + raise DeserializationException + + def deserialize_many(self, *args, **kw): + """Immediately raises a :exc:`DeserializationException`.""" + raise DeserializationException + + def isclass(obj): """Returns ``True`` if and only if the specified object is a type (or a class). diff --git a/tests/test_creating.py b/tests/test_creating.py index aef207fd..7ad916fe 100644 --- a/tests/test_creating.py +++ b/tests/test_creating.py @@ -39,9 +39,8 @@ from flask.ext.restless import APIManager from flask.ext.restless import CONTENT_TYPE -from flask.ext.restless import DeserializationException -from flask.ext.restless import SerializationException -from flask.ext.restless import simple_serialize +from flask.ext.restless import DefaultDeserializer +from flask.ext.restless import DefaultSerializer from .helpers import BetterJSONEncoder as JSONEncoder from .helpers import check_sole_error @@ -51,27 +50,8 @@ from .helpers import ManagerTestBase from .helpers import MSIE8_UA from .helpers import MSIE9_UA - - -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() +from .helpers import raise_s_exception +from .helpers import raise_d_exception class TestCreating(ManagerTestBase): @@ -549,23 +529,27 @@ def test_custom_serialization(self): """Tests for custom deserialization.""" temp = [] - def serializer(instance, *args, **kw): - result = simple_serialize(instance) - result['attributes']['foo'] = temp.pop() - return result + class MySerializer(DefaultSerializer): + + def serialize(self, *args, **kw): + result = super(MySerializer, self).serialize(*args, **kw) + result['data']['attributes']['foo'] = temp.pop() + return result - def deserializer(document, *args, **kw): - # Move the attributes up to the top-level object. - data = document['data']['attributes'] - temp.append(data.pop('foo')) - instance = self.Person(**data) - return instance + class MyDeserializer(DefaultDeserializer): + + def deserialize(self, document, *args, **kw): + # Remove the extra 'foo' attribute and stash it in the + # `temp` list. + temp.append(document['data']['attributes'].pop('foo')) + super_deserialize = super(MyDeserializer, self).deserialize + return super_deserialize(document, *args, **kw) # POST will deserialize once and serialize once self.manager.create_api(self.Person, methods=['POST'], url_prefix='/api2', - serializer=serializer, - deserializer=deserializer) + serializer_class=MySerializer, + deserializer_class=MyDeserializer) data = dict(data=dict(type='person', attributes=dict(foo='bar'))) response = self.app.post('/api2/person', data=dumps(data)) assert response.status_code == 201 @@ -583,7 +567,7 @@ def test_serialization_exception_included(self): self.session.commit() self.manager.create_api(self.Article, methods=['POST'], url_prefix='/api2') - self.manager.create_api(self.Person, serializer=raise_s_exception) + self.manager.create_api(self.Person, serializer_class=raise_s_exception) data = { 'data': { 'type': 'article', @@ -611,24 +595,24 @@ def test_deserialization_exception(self): """ self.manager.create_api(self.Person, methods=['POST'], url_prefix='/api2', - deserializer=raise_d_exception) + deserializer_class=raise_d_exception) data = dict(data=dict(type='person')) response = self.app.post('/api2/person', data=dumps(data)) assert response.status_code == 400 # TODO check error message here def test_serialization_exception(self): - """Tests that exceptions are caught when a custom serialization method - raises an exception. + """Tests that exceptions are caught when a custom serialization + method raises an exception. """ self.manager.create_api(self.Person, methods=['POST'], url_prefix='/api2', - serializer=raise_s_exception) + serializer_class=raise_s_exception) data = dict(data=dict(type='person')) response = self.app.post('/api2/person', data=dumps(data)) - assert response.status_code == 400 - # TODO check error message here + check_sole_error(response, 500, ['Failed to serialize', 'type', + 'person', 'ID', '1']) def test_to_one_related_resource_url(self): """Tests that attempting to add to a to-one related resource URL @@ -669,6 +653,30 @@ def test_missing_data(self): keywords = ['deserialize', 'missing', '"data"', 'element'] check_sole_error(response, 400, keywords) + def test_to_one_relationship_missing_data(self): + """Tests that the server rejects a request to create a resource + with a to-one relationship when the relationship object is + missing a ``data`` element. + + """ + person = self.Person(id=1) + self.session.add(person) + self.session.commit() + data = { + 'data': { + 'type': 'article', + 'relationships': { + 'author': { + 'type': 'person' + } + } + } + } + response = self.app.post('/api/article', data=dumps(data)) + keywords = ['deserialize', 'missing', '"data"', 'element', + 'linkage object', 'relationship', '"author"'] + check_sole_error(response, 400, keywords) + def test_to_one_relationship_missing_id(self): """Tests that the server rejects a request to create a resource with a to-one relationship when the relationship linkage object @@ -801,6 +809,31 @@ def test_to_many_relationship_missing_type(self): 'linkage object', 'relationship', '"articles"'] check_sole_error(response, 400, keywords) + def test_to_many_relationship_not_a_list(self): + """Tests that the server rejects a request to create a resource + with a to-many relationship when the relationship is not a list. + + """ + article = self.Article(id=1) + self.session.add(article) + self.session.commit() + data = { + 'data': { + 'type': 'person', + 'relationships': { + 'articles': { + 'data': { + 'id': '1' + } + } + } + } + } + response = self.app.post('/api/person', data=dumps(data)) + keywords = ['data', 'in linkage', 'relationship', '"articles"', + 'must be a list'] + check_sole_error(response, 400, keywords) + def test_to_many_relationship_conflicting_type(self): """Tests that the server rejects a request to create a resource with a to-many relationship when any of the relationship linkage diff --git a/tests/test_fetching.py b/tests/test_fetching.py index 28df9744..c3d0c25d 100644 --- a/tests/test_fetching.py +++ b/tests/test_fetching.py @@ -30,8 +30,8 @@ from sqlalchemy.orm import relationship from flask.ext.restless import APIManager +from flask.ext.restless import DefaultSerializer from flask.ext.restless import ProcessingException -from flask.ext.restless import simple_serialize from .helpers import check_sole_error from .helpers import dumps @@ -874,19 +874,21 @@ def test_additional_attributes_object(self): 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 + class MySerializer(DefaultSerializer): + + def serialize(self, *args, **kw): + result = super(MySerializer, self).serialize(*args, **kw) + if 'attributes' not in result['data']: + result['data']['attributes'] = {} + result['data']['attributes']['foo'] = 'foo' + return result self.manager.create_api(self.Article, additional_attributes=['first_comment']) # 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) + self.manager.create_api(self.Comment, serializer_class=MySerializer) # 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.Person) diff --git a/tests/test_jsonapi/test_fetching_data.py b/tests/test_jsonapi/test_fetching_data.py index 8e6e9e1a..086fc372 100644 --- a/tests/test_jsonapi/test_fetching_data.py +++ b/tests/test_jsonapi/test_fetching_data.py @@ -419,8 +419,8 @@ class Person(self.Base): self.manager.create_api(Person) def test_default_inclusion(self): - """Tests that by default, Flask-Restless includes no information - in compound documents. + """Tests that by default, Flask-Restless includes no included + resources in compound documents. For more information, see the `Inclusion of Related Resources`_ section of the JSON API specification. @@ -440,7 +440,10 @@ def test_default_inclusion(self): person = document['data'] articles = person['relationships']['articles']['data'] assert ['1'] == sorted(article['id'] for article in articles) - assert 'included' not in document + # The current implementation of Flask-Restless has an empty list + # for `included`. + assert document['included'] == [] + # assert 'included' not in document def test_set_default_inclusion(self): """Tests that the user can specify default compound document @@ -765,8 +768,9 @@ def test_sparse_fieldsets_multiple_types(self): """ article = self.Article(id=1, title=u'bar') - person = self.Person(id=1, name=u'foo', age=99, articles=[article]) - self.session.add_all([person, article]) + person = self.Person(id=1, name=u'foo', age=99) + article.author = person + self.session.add_all([article, person]) self.session.commit() # Person objects should only have ID and name, while article objects # should only have ID. diff --git a/tests/test_jsonapi/test_updating_resources.py b/tests/test_jsonapi/test_updating_resources.py index 5843fe15..67fb64cb 100644 --- a/tests/test_jsonapi/test_updating_resources.py +++ b/tests/test_jsonapi/test_updating_resources.py @@ -262,11 +262,14 @@ def test_other_modifications(self): tag = self.Tag(id=1) self.session.add(tag) self.session.commit() - data = {'data': - {'type': 'tag', - 'id': '1', - 'attributes': {'name': u'foo'} + data = { + 'data': { + 'type': 'tag', + 'id': '1', + 'attributes': { + 'name': u'foo' } + } } response = self.app.patch('/api/tag/1', data=dumps(data)) assert response.status_code == 200 diff --git a/tests/test_manager.py b/tests/test_manager.py index 55be5453..c5de91c4 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -22,6 +22,7 @@ from flask.ext.restless import APIManager from flask.ext.restless import collection_name +from flask.ext.restless import DefaultSerializer from flask.ext.restless import IllegalArgumentError from flask.ext.restless import model_for from flask.ext.restless import serializer_for @@ -273,6 +274,7 @@ class Person(self.Base): __tablename__ = 'person' id = Column(Integer, primary_key=True) name = Column(Unicode) + extra = 'foo' class Article(self.Base): __tablename__ = 'article' @@ -367,11 +369,12 @@ def test_serializer_for(self): function. """ - def my_function(*args, **kw): + + class MySerializer(DefaultSerializer): pass - self.manager.create_api(self.Person, serializer=my_function) - assert serializer_for(self.Person) == my_function + self.manager.create_api(self.Person, serializer_class=MySerializer) + assert isinstance(serializer_for(self.Person), MySerializer) def test_serializer_for_nonexistent(self): """Tests that attempting to get the serializer for an unknown @@ -464,6 +467,16 @@ def test_additional_attributes_nonexistent(self): self.manager.create_api(self.Person, additional_attributes=['bogus']) + def test_exclude_additional_attributes(self): + """Tests that an attempt to exclude a field that is also + specified in ``additional_attributes`` causes an exception at + the time of API creation. + + """ + with self.assertRaises(IllegalArgumentError): + self.manager.create_api(self.Person, exclude=['extra'], + additional_attributes=['extra']) + class TestFSA(FlaskSQLAlchemyTestBase): """Tests which use models defined using Flask-SQLAlchemy instead of pure diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 5a204d35..35e18422 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -36,24 +36,15 @@ from sqlalchemy.orm import backref from sqlalchemy.orm import relationship -from flask.ext.restless import simple_serialize +from flask.ext.restless import DefaultSerializer +from flask.ext.restless import MultipleExceptions 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) +from .helpers import raise_s_exception as raise_exception class DecoratedDateTime(TypeDecorator): @@ -79,6 +70,100 @@ class Person(self.Base): self.Person = Person self.Base.metadata.create_all() + def test_custom_serializer(self): + """Tests for a custom serializer for serializing many resources. + + """ + person1 = self.Person(id=1) + person2 = self.Person(id=2) + self.session.add_all([person1, person2]) + self.session.commit() + + class MySerializer(DefaultSerializer): + + def serialize_many(self, *args, **kw): + result = super(MySerializer, self).serialize_many(*args, **kw) + for resource in result['data']: + if 'attributes' not in resource: + resource['attributes'] = {} + resource['attributes']['foo'] = resource['id'] + return result + + self.manager.create_api(self.Person, serializer_class=MySerializer) + + response = self.app.get('/api/person') + document = loads(response.data) + people = document['data'] + attributes = sorted(person['attributes']['foo'] for person in people) + assert ['1', '2'] == attributes + + def test_multiple_exceptions(self): + """Tests that multiple exceptions are caught when serializing + many instances. + + """ + person1 = self.Person(id=1) + person2 = self.Person(id=2) + self.session.add_all([person1, person2]) + self.session.commit() + + class raise_exceptions(DefaultSerializer): + + def serialize_many(self, instances, *args, **kw): + instance1, instance2 = instances[:2] + exception1 = SerializationException(instance1, message='foo') + exception2 = SerializationException(instance2, message='bar') + exceptions = [exception1, exception2] + raise MultipleExceptions(exceptions) + + self.manager.create_api(self.Person, serializer_class=raise_exceptions) + + response = self.app.get('/api/person') + document = loads(response.data) + assert response.status_code == 500 + errors = document['errors'] + assert len(errors) == 2 + error1, error2 = errors + detail1 = error1['detail'] + assert 'foo' in detail1 + detail2 = error2['detail'] + assert 'bar' in detail2 + + def test_multiple_exceptions_from_dump(self): + """Tests that multiple exceptions are caught from the + :meth:`DefaultSerializer._dump` method when serializing many + instances. + + """ + person1 = self.Person(id=1) + person2 = self.Person(id=2) + self.session.add_all([person1, person2]) + self.session.commit() + + class raise_exceptions(DefaultSerializer): + + def _dump(self, instance, *args, **kw): + message = 'failed on {0}'.format(instance.id) + raise SerializationException(instance, message=message) + + self.manager.create_api(self.Person, serializer_class=raise_exceptions) + + response = self.app.get('/api/person') + document = loads(response.data) + assert response.status_code == 500 + errors = document['errors'] + print(errors) + assert len(errors) == 2 + error1, error2 = errors + detail1 = error1['detail'] + detail2 = error2['detail'] + # There is no guarantee on the order in which the error objects + # are supplied in the response, so we check which is which. + if '1' not in detail1: + detail1, detail2 = detail2, detail1 + assert u'failed on 1' in detail1 + assert u'failed on 2' in detail2 + def test_exception_single(self): """Tests for a serialization exception on a filtered single response. @@ -88,7 +173,7 @@ def test_exception_single(self): self.session.add(person) self.session.commit() - self.manager.create_api(self.Person, serializer=raise_exception) + self.manager.create_api(self.Person, serializer_class=raise_exception) query_string = {'filter[single]': 1} response = self.app.get('/api/person', query_string=query_string) @@ -242,18 +327,20 @@ def test_type_decorator_interval(self): person = document['data'] assert person['attributes']['decorated_interval'] == 10 - def test_custom_function(self): + def test_custom_serialize(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 + class MySerializer(DefaultSerializer): - self.manager.create_api(self.Person, serializer=serializer) + def serialize(self, *args, **kw): + result = super(MySerializer, self).serialize(*args, **kw) + result['data']['attributes']['foo'] = 'bar' + return result + + self.manager.create_api(self.Person, serializer_class=MySerializer) response = self.app.get('/api/person/1') document = loads(response.data) person = document['data'] @@ -272,22 +359,25 @@ def test_per_model_serializer_on_included(self): 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 + class MySerializer(DefaultSerializer): + + secret = None + + def serialize(self, *args, **kw): + result = super(MySerializer, self).serialize(*args, **kw) + if 'attributes' not in result['data']: + result['data']['attributes'] = {} + result['data']['attributes'][self.secret] = self.secret + 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 + class FooSerializer(MySerializer): + secret = 'foo' - self.manager.create_api(self.Person, serializer=add_foo) - self.manager.create_api(self.Article, serializer=add_bar) + class BarSerializer(MySerializer): + secret = 'bar' + + self.manager.create_api(self.Person, serializer_class=FooSerializer) + self.manager.create_api(self.Article, serializer_class=BarSerializer) query_string = {'include': 'author'} response = self.app.get('/api/article/1', query_string=query_string) @@ -313,7 +403,7 @@ def test_exception(self): self.session.add(person) self.session.commit() - self.manager.create_api(self.Person, serializer=raise_exception) + self.manager.create_api(self.Person, serializer_class=raise_exception) response = self.app.get('/api/person/1') check_sole_error(response, 500, ['Failed to serialize', 'type', @@ -331,7 +421,7 @@ def test_exception_on_included(self): self.session.commit() self.manager.create_api(self.Person) - self.manager.create_api(self.Article, serializer=raise_exception) + self.manager.create_api(self.Article, serializer_class=raise_exception) query_string = {'include': 'articles'} response = self.app.get('/api/person/1', query_string=query_string) @@ -354,7 +444,7 @@ def test_multiple_exceptions_on_included(self): self.session.commit() self.manager.create_api(self.Article) - self.manager.create_api(self.Person, serializer=raise_exception) + self.manager.create_api(self.Person, serializer_class=raise_exception) query_string = {'include': 'author'} response = self.app.get('/api/article', query_string=query_string) @@ -420,10 +510,12 @@ def test_exception_message(self): self.session.add(person) self.session.commit() - def raise_with_msg(instance, *args, **kw): - raise SerializationException(instance, message='foo') + class raise_with_msg(DefaultSerializer): + + def serialize(self, instance, *args, **kw): + raise SerializationException(instance, message='foo') - self.manager.create_api(self.Person, serializer=raise_with_msg) + self.manager.create_api(self.Person, serializer_class=raise_with_msg) response = self.app.get('/api/person/1') check_sole_error(response, 500, ['foo']) @@ -452,7 +544,7 @@ class Person(self.Base): def test_exception_to_many(self): """Tests that exceptions are caught when a custom serialization method - raises an exception on a to-one relation. + raises an exception on a to-many relation. """ person = self.Person(id=1) @@ -462,7 +554,7 @@ def test_exception_to_many(self): self.session.commit() self.manager.create_api(self.Person) - self.manager.create_api(self.Article, serializer=raise_exception) + self.manager.create_api(self.Article, serializer_class=raise_exception) response = self.app.get('/api/person/1/articles') check_sole_error(response, 500, ['Failed to serialize', 'type', @@ -479,7 +571,7 @@ def test_exception_to_one(self): self.session.add_all([person, article]) self.session.commit() - self.manager.create_api(self.Person, serializer=raise_exception) + self.manager.create_api(self.Person, serializer_class=raise_exception) self.manager.create_api(self.Article) response = self.app.get('/api/article/1/author') @@ -497,7 +589,7 @@ def test_exception_on_included(self): self.session.add_all([article, person]) self.session.commit() - self.manager.create_api(self.Person, serializer=raise_exception) + self.manager.create_api(self.Person, serializer_class=raise_exception) self.manager.create_api(self.Article) params = {'include': 'author'} @@ -540,7 +632,7 @@ def test_exception(self): self.session.commit() self.manager.create_api(self.Person) - self.manager.create_api(self.Article, serializer=raise_exception) + self.manager.create_api(self.Article, serializer_class=raise_exception) response = self.app.get('/api/person/1/articles/1') check_sole_error(response, 500, ['Failed to serialize', 'type', @@ -557,7 +649,7 @@ def test_exception_on_included(self): self.session.add_all([article, person]) self.session.commit() - self.manager.create_api(self.Person, serializer=raise_exception) + self.manager.create_api(self.Person, serializer_class=raise_exception) self.manager.create_api(self.Article) query_string = {'include': 'author'} diff --git a/tests/test_updating.py b/tests/test_updating.py index 5051af13..b83b6c2a 100644 --- a/tests/test_updating.py +++ b/tests/test_updating.py @@ -33,6 +33,7 @@ from sqlalchemy import Date from sqlalchemy import DateTime from sqlalchemy import ForeignKey +from sqlalchemy import func from sqlalchemy import Integer from sqlalchemy import Time from sqlalchemy import Unicode @@ -53,6 +54,7 @@ from .helpers import MSIE8_UA from .helpers import MSIE9_UA from .helpers import ManagerTestBase +from .helpers import raise_s_exception as raise_exception class TestUpdating(ManagerTestBase): @@ -106,9 +108,17 @@ def radius(self): def radius(cls): return cls.length / 2 + class Tag(self.Base): + __tablename__ = 'tag' + id = Column(Integer, primary_key=True) + name = Column(Unicode) + updated_at = Column(DateTime, server_default=func.now(), + onupdate=func.current_timestamp()) + self.Article = Article self.Interval = Interval self.Person = Person + self.Tag = Tag self.Base.metadata.create_all() self.manager.create_api(Article, methods=['PATCH']) self.manager.create_api(Interval, methods=['PATCH']) @@ -852,6 +862,33 @@ def test_relationship_missing_object(self): # Check that the article was not updated to None. assert article.author is person + def test_serialization_exception(self): + """Tests that serialization exceptions are caught when + responding with content. + + A representation of the modified resource is returned to the + client when an update causes additional changes in the resource + in ways other than those specified by the client. + + """ + tag = self.Tag(id=1) + self.session.add(tag) + self.session.commit() + self.manager.create_api(self.Tag, methods=['PATCH'], + serializer_class=raise_exception) + data = { + 'data': { + 'type': 'tag', + 'id': '1', + 'attributes': { + 'name': u'foo' + } + } + } + response = self.app.patch('/api/tag/1', data=dumps(data)) + check_sole_error(response, 500, ['Failed to serialize', 'type', 'tag', + 'ID', '1']) + class TestProcessors(ManagerTestBase): """Tests for pre- and postprocessors."""