diff --git a/docs/customizing.rst b/docs/customizing.rst index feaee5ca..f632fbee 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -127,58 +127,78 @@ Bulk operations via the JSON API Bulk extension are not yet supported. Custom serialization ~~~~~~~~~~~~~~~~~~~~ -.. versionadded:: 0.17.0 - 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(...) + + def deserialize_many(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`. .. note:: @@ -197,21 +217,33 @@ 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 + 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 diff --git a/examples/server_configurations/custom_serialization.py b/examples/server_configurations/custom_serialization.py index 225fceab..2c09e1c6 100644 --- a/examples/server_configurations/custom_serialization.py +++ b/examples/server_configurations/custom_serialization.py @@ -49,10 +49,10 @@ .. _marshmallow-jsonapi: https://marshmallow-jsonapi.readthedocs.org """ -from functools import partial - from flask import Flask from flask.ext.restless import APIManager +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 @@ -63,6 +63,7 @@ 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 ## @@ -132,35 +133,60 @@ class Meta: def make_article(self, data): return Article(**data) -person_schema = PersonSchema() -article_schema = ArticleSchema() + +## 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 -## Serializer and deserializer functions ## +class PersonDeserializer(MarshmallowDeserializer): + schema_class = PersonSchema -def _dump(schema, instance, only=None): - # In this example, we will ignore the `only` keyword argument, which - # is the client's request for which fields to include in the response. - return schema.dump(instance).data +class ArticleSerializer(MarshmallowSerializer): + schema_class = ArticleSchema -def _load(schema, document): - return schema.load(document).data +class ArticleDeserializer(MarshmallowDeserializer): + schema_class = ArticleSchema -person_serializer = partial(_dump, person_schema) -person_deserializer = partial(_load, person_schema) -article_serializer = partial(_dump, article_schema) -article_deserializer = partial(_load, article_schema) +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(Article, methods=['GET', 'POST'], - serializer=article_serializer, - deserializer=article_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..b2531d57 100644 --- a/flask_restless/__init__.py +++ b/flask_restless/__init__.py @@ -29,10 +29,13 @@ 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 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..a50c715e 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. @@ -631,30 +624,27 @@ def create_api_blueprint(self, name, model, methods=READONLY_METHODS, raise AttributeError(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 +687,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 +751,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 index 0b01d776..08296cd6 100644 --- a/flask_restless/serialization.py +++ b/flask_restless/serialization.py @@ -53,6 +53,10 @@ from .helpers import strings_to_datetimes from .helpers import url_for +#: The highest version of the JSON API specification supported by +#: Flask-Restless. +JSONAPI_VERSION = '1.0' + #: 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', ) @@ -335,31 +339,60 @@ def create_relationship(model, instance, relation): # 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] + result['data'] = list(map(simple_relationship_dump, related_value)) elif related_value is not None: - result['data'] = simple_relationship_serialize(related_value) + 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 + + class Serializer(object): - """An object that, when called, returns a dictionary representation of a - given instance of a SQLAlchemy model. + """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 __call__(self, instance, only=None): - """Returns a dictionary representation of the specified instance of a - SQLAlchemy model. + 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. The only exception is that the keys ``'id'`` and ``'type'`` - will always appear, regardless of whether they appear in `only`. + 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.** @@ -369,14 +402,15 @@ def __call__(self, instance, only=None): class Deserializer(object): - """An object that, when called, returns an instance of the SQLAlchemy model + """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:`__call__` method. + :meth:`.deserialize` and :meth:`.deserialize_many` methods. **This is a base class with no implementation.** @@ -386,10 +420,10 @@ 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. + 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 @@ -397,41 +431,66 @@ def __call__(self, document): see the `Resource Objects`_ section of the JSON API specification. - **This method is not implemented in this base class; subclasses must - override this method.** + **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. -class DefaultSerializer(Serializer): - """A default implementation of a serializer for SQLAlchemy models. + `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. - When called, this object returns a dictionary representation of a given - SQLAlchemy instance that meets the requirements of the JSON API - specification. + **This method is not implemented in this base class; subclasses + must override this method.** - 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. + .. _Resource Objects: http://jsonapi.org/format/#document-structure-resource-objects + + """ + pass - 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. +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 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. + 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. + + 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. """ @@ -463,36 +522,10 @@ def __init__(self, only=None, exclude=None, additional_attributes=None, 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. + 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 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) @@ -535,6 +568,7 @@ def __call__(self, instance, only=None): # this particular instance. # # TODO In Python 2.7 and later, this should be a dict comprehension. + columns=list(columns) attributes = dict((column, getattr(instance, column)) for column in columns) # Call any functions that appear in the result. @@ -569,13 +603,17 @@ def __call__(self, instance, only=None): if is_mapped_class(type(val)): model_ = get_model(val) try: - serialize = serializer_for(model_) + 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) - serialize = simple_serialize - attributes[key] = serialize(val) + 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) @@ -647,6 +685,47 @@ def __call__(self, instance, only=None): 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 these are homogeneous. + resources = [self._dump(instance, only=only) for instance in instances] + result = JsonApiDocument() + result['data'] = resources + return result + class DefaultRelationshipSerializer(Serializer): """A default implementation of a serializer for resource identifier @@ -654,14 +733,29 @@ class DefaultRelationshipSerializer(Serializer): 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. + dictionary returned by the :meth:`.serialize` and + :meth:`.serialize_many` methods. """ - def __call__(self, instance, only=None, _type=None): + def _dump(self, instance, _type=None): if _type is None: _type = collection_name(get_model(instance)) - return {'id': str(primary_key_value(instance)), 'type': _type} + 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): + resource_identifier = [self._dump(instance, _type=_type) + for instance in instances] + result = JsonApiDocument() + result['data'] = resource_identifier + return result class DefaultDeserializer(Deserializer): @@ -678,20 +772,7 @@ def __init__(self, session, model, allow_client_generated_ids=False, **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'] + def _load(self, data): if 'type' not in data: raise MissingType if 'id' in data and not self.allow_client_generated_ids: @@ -716,15 +797,19 @@ def __call__(self, document): 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) + deserializer = DRD(self.session, related_model, link_name) + #if 'data' not in link_object: + # raise MissingData(link_name) + #linkage = link_object['data'] + #expected_type = collection_name(related_model) + # Create the deserializer for this relationship object. + if is_like_list(self.model, link_name): + deserialize = deserializer.deserialize_many + else: + deserialize = deserializer.deserialize + 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... @@ -741,6 +826,33 @@ def __call__(self, document): 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) + + def deserialize_many(self, document): + if 'data' not in document: + raise MissingData + data = document['data'] + if not isinstance(data, list): + msg = ('data element must be a list when calling' + ' deserialize_many(); maybe you meant to call' + ' deserialize()?') + raise DeserializationException(msg) + return [self._load(resource) for resource in data] + class DefaultRelationshipDeserializer(Deserializer): """A default implementation of a deserializer for resource @@ -750,8 +862,9 @@ class DefaultRelationshipDeserializer(Deserializer): 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. + 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. @@ -780,7 +893,7 @@ def __init__(self, session, model, relation_name=None): #: The name of the relationship being deserialized, as a string. self.relation_name = relation_name - def __call__(self, data): + def _load(self, data): """Gets the resource associated with the given resource identifier object. @@ -798,32 +911,78 @@ def __call__(self, data): """ # 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 + 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): + if 'data' not in document: + raise MissingData(self.relation_name) + resource_identifier = document['data'] + return self._load(resource_identifier) + + def deserialize_many(self, document): + if 'data' not in document: + raise MissingData(self.relation_name) + resource_identifiers = document['data'] + if not isinstance(resource_identifiers, list): + msg = ('data element must be a list when calling' + ' deserialize_many(); maybe you meant to call' + ' deserialize()?') + raise DeserializationException(msg) + return list(map(self._load, resource_identifiers)) + + +#: This is an instance of the default serializer class, #: :class:`DefaultSerializer`. #: -#: This function is suitable for calling on its own, no other instantiation or -#: customization necessary. -simple_serialize = DefaultSerializer() +#: The purpose of this instance is to provide easy access to default +#: serialization methods. +singleton_serializer = DefaultSerializer() + +#: 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_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 -#: Basic serializer for relationship objects. +#: 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 = DefaultRelationshipSerializer() +#: 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 f5732004..c9577de5 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,10 +60,10 @@ from ..search import search from ..search import search_relationship from ..search import UnknownField -from ..serialization import simple_serialize -from ..serialization import simple_relationship_serialize -from ..serialization import DefaultDeserializer from ..serialization import DeserializationException +from ..serialization import JsonApiDocument +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 @@ -754,6 +755,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 @@ -815,6 +817,77 @@ def errors_from_serialization_exceptions(exceptions, included=False): 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. @@ -1102,9 +1175,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`. """ @@ -1151,17 +1233,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. @@ -1246,55 +1328,23 @@ 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. + # def _serialize_many(self, instances, relationship=False): + # """Serializes a list of SQLAlchemy objects. - `instances` is a list of SQLAlchemy objects of any model class. + # `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`. + # 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. + # 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`. + # 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 + # """ + # pass def get_all_inclusions(self, instance_or_instances): """Returns a list of all the requested included resources @@ -1325,9 +1375,43 @@ def get_all_inclusions(self, instance_or_instances): for resource in instance_or_instances)) else: to_include = self.resources_to_include(instance_or_instances) + # TODO Ideally, we would like to use Serializer.serialize_many + # here. However, it currently does not support serializing + # heterogeneous collections. It could be made to work, though... + # # This may raise MultipleExceptions if there are problems # serializing the included resources. - return self._serialize_many(to_include) + result = [] + failed = [] + for instance in to_include: + model = get_model(instance) + # 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: + 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.) + serialized = serialized['data'] + result.append(serialized) + except SerializationException as exception: + failed.append(exception) + if failed: + raise MultipleExceptions(failed) + return result def _collection_parameters(self): """Gets filtering, sorting, grouping, and other settings from the @@ -1408,9 +1492,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 @@ -1423,15 +1507,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)) @@ -1469,13 +1564,18 @@ 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) + # # Serialize the found items. This may raise an exception if + # # there is a problem serializing any of the objects. + # 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) + # 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) @@ -1487,45 +1587,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: @@ -1540,11 +1649,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) @@ -1577,11 +1688,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. # @@ -1596,39 +1703,108 @@ def _get_collection_helper(self, resource=None, relation_name=None, 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`) + # + try: + 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) + # This may raise SerializationException + 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). @@ -1646,8 +1822,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. @@ -1664,9 +1841,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 29f48d20..eb5fef7f 100644 --- a/flask_restless/views/resources.py +++ b/flask_restless/views/resources.py @@ -372,17 +372,17 @@ 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: @@ -396,14 +396,13 @@ def post(self): return error_response(400, cause=exception, detail=detail) 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) @@ -412,8 +411,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) @@ -425,7 +422,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) @@ -614,7 +611,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 35f7c128..5862e80e 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/test_creating.py b/tests/test_creating.py index 87bfcb87..6e3c9895 100644 --- a/tests/test_creating.py +++ b/tests/test_creating.py @@ -38,9 +38,10 @@ from flask.ext.restless import APIManager from flask.ext.restless import CONTENT_TYPE +from flask.ext.restless import DefaultDeserializer +from flask.ext.restless import DefaultSerializer from flask.ext.restless import DeserializationException from flask.ext.restless import SerializationException -from flask.ext.restless import simple_serialize from .helpers import BetterJSONEncoder as JSONEncoder from .helpers import check_sole_error @@ -53,25 +54,45 @@ from .helpers import skip -def raise_s_exception(instance, *args, **kw): - """Immediately raises a :exc:`SerializationException` with access to - the provided `instance` of a SQLAlchemy model. +class raise_s_exception(DefaultSerializer): + """A serializer that unconditionally raises an exception when + either :meth:`.serialize` or :meth:`.serialize_many` is called. - This function is useful for use in tests for serialization - exceptions. + This class is useful for tests of serialization exceptions. """ - raise SerializationException(instance) + def serialize(self, instance, *args, **kw): + """Immediately raises a :exc:`SerializationException` with + access to the provided `instance` of a SQLAlchemy model. -def raise_d_exception(*args, **kw): - """Immediately raises a :exc:`DeserializationException`. + """ + 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 function is useful for use in tests for deserialization - exceptions. + This class is useful for tests of deserialization exceptions. """ - raise DeserializationException() + + 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 class TestCreating(ManagerTestBase): @@ -549,23 +570,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 + + class MyDeserializer(DefaultDeserializer): - 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 + 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 +608,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 +636,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 diff --git a/tests/test_fetching.py b/tests/test_fetching.py index 6db73fa7..240165cb 100644 --- a/tests/test_fetching.py +++ b/tests/test_fetching.py @@ -29,8 +29,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 fd7d30ff..a4573527 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_manager.py b/tests/test_manager.py index d93d390e..4b96a177 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -21,6 +21,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 @@ -367,11 +368,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) @raises(ValueError) def test_serializer_for_nonexistent(self): diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 4eb83c3f..ed775eed 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -36,7 +36,8 @@ 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 Serializer from flask.ext.restless import SerializationException from .helpers import check_sole_error @@ -45,15 +46,28 @@ 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. +class raise_exception(DefaultSerializer): + """A serializer that unconditionally raises an exception when + either :meth:`.serialize` or :meth:`.serialize_many` is called. - This function is useful for use in tests for serialization - exceptions. + This class is useful for tests of serialization exceptions. """ - raise SerializationException(instance) + + 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 DecoratedDateTime(TypeDecorator): @@ -79,6 +93,33 @@ 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_exception_single(self): """Tests for a serialization exception on a filtered single response. @@ -88,7 +129,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 +283,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 +315,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 +359,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 +377,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 +400,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 +466,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 +500,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 +510,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 +527,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 +545,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 +588,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 +605,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'}