Skip to content

Commit

Permalink
Deserialier now accepts full JSON API document.
Browse files Browse the repository at this point in the history
Before, the deserializer only accepted the resource object as input, now
it expects the complete JSON API document received in the request from
the client.
  • Loading branch information
jfinkels committed Mar 13, 2016
1 parent 9d5d356 commit dd4c0d7
Show file tree
Hide file tree
Showing 7 changed files with 137 additions and 66 deletions.
23 changes: 23 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ API
This part of the documentation documents all the public classes and functions
in Flask-Restless.

The API Manager class
---------------------

.. autoclass:: APIManager

.. automethod:: init_app
Expand All @@ -14,6 +17,9 @@ in Flask-Restless.

.. automethod:: create_api_blueprint

Global helper functions
-----------------------

.. autofunction:: collection_name(model, _apimanager=None)

.. autofunction:: model_for(collection_name, _apimanager=None)
Expand All @@ -22,4 +28,21 @@ in Flask-Restless.

.. autofunction:: url_for(model, instid=None, relationname=None, relationinstid=None, _apimanager=None, **kw)

Serialization helpers
---------------------

.. autofunction:: simple_serialize(instance, only=None)

.. autoclass:: Serializer

.. autoclass:: Deserializer

.. autoclass:: SerializationException

.. autoclass:: DeserializationException


Pre- and postprocessor helpers
------------------------------

.. autoclass:: ProcessingException
32 changes: 26 additions & 6 deletions docs/customizing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -143,22 +143,42 @@ client will receive non-compliant responses!
Define your serialization functions like this::

def serialize(instance, only=None):
return {'data': ...}
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 object.
dictionary representation of the resource object.

Define your deserialization function like this::
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::

def deserialize(data):
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(...)

``data`` is a dictionary representation of an instance of the model. The
function must return return an instance of `model` that has those attributes.
``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.

.. note::

Expand Down
7 changes: 3 additions & 4 deletions flask_restless/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -625,12 +625,12 @@ def create_api_blueprint(self, name, model, methods=READONLY_METHODS,
# session = self.restlessinfo.session
session = self.session
if deserializer is None:
deserializer = DefaultDeserializer(self.session, model)
deserializer = DefaultDeserializer(self.session, model,
allow_client_generated_ids)
# 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
acgi = allow_client_generated_ids
api_view = API.as_view(apiname, session, model,
# Keyword arguments for APIBase.__init__()
preprocessors=preprocessors_,
Expand All @@ -643,8 +643,7 @@ def create_api_blueprint(self, name, model, methods=READONLY_METHODS,
max_page_size=max_page_size,
serializer=serializer,
deserializer=deserializer,
includes=includes,
allow_client_generated_ids=acgi)
includes=includes)

# add the URL rules to the blueprint: the first is for methods on the
# collection only, the second is for methods which may or may not
Expand Down
80 changes: 62 additions & 18 deletions flask_restless/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
from sqlalchemy.exc import NoInspectionAvailable
from sqlalchemy.ext.hybrid import HYBRID_PROPERTY
from sqlalchemy.inspection import inspect
from sqlalchemy.orm.query import Query
from werkzeug.routing import BuildError
from werkzeug.urls import url_quote_plus

Expand Down Expand Up @@ -125,6 +124,18 @@ def message(self):
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.
Expand All @@ -140,7 +151,8 @@ class ConflictingType(DeserializationException):
"""

def __init__(self, relation_name, expected_type, given_type, *args, **kw):
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.
Expand All @@ -152,9 +164,14 @@ def __init__(self, relation_name, expected_type, given_type, *args, **kw):
#: The type name given by the client for the related model.
self.given_type = given_type

detail = ('expected type "{0}" but got type "{1}" in linkage object'
' for relationship "{2}"')
self.detail = detail.format(expected_type, given_type, relation_name)
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):
Expand Down Expand Up @@ -210,15 +227,20 @@ class MissingInformation(DeserializationException):
#: Subclasses must set this class attribute.
element = None

def __init__(self, relation_name, *args, **kw):
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

detail = ('missing "{0}" element in linkage object for relationship'
' "{1}"')
self.detail = detail.format(self.element, relation_name)
if relation_name is None:
detail = 'missing "{0}" element'
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):
Expand Down Expand Up @@ -364,14 +386,16 @@ def __init__(self, session, model):
self.session = session
self.model = model

def __call__(self, data):
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.
`data` must be a dictionary representation of a resource as specified
in the JSON API specification. For more information, see the `Resource
Objects`_ section of the JSON API specification.
`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.**
Expand Down Expand Up @@ -643,19 +667,39 @@ def __call__(self, instance, only=None, _type=None):
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.
When called, this object returns an instance of a SQLAlchemy model
with fields and relations specified by the provided dictionary.
"""

def __call__(self, data):
"""Creates and returns an instance of the SQLAlchemy model specified in
the constructor.
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:
Expand Down
12 changes: 7 additions & 5 deletions flask_restless/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1114,7 +1114,7 @@ class APIBase(ModelView):
def __init__(self, session, model, preprocessors=None, postprocessors=None,
primary_key=None, serializer=None, deserializer=None,
validation_exceptions=None, includes=None, page_size=10,
max_page_size=100, allow_to_many_replacement=None, *args,
max_page_size=100, allow_to_many_replacement=False, *args,
**kw):
super(APIBase, self).__init__(session, model, *args, **kw)

Expand Down Expand Up @@ -1149,17 +1149,19 @@ def __init__(self, session, model, preprocessors=None, postprocessors=None,
#: A custom serialization function for primary resources; see
#: :ref:`serialization` for more information.
#:
#: Use our default serializer if none is specified.
self.serialize = serializer or simple_serialize
#: This should not be ``None``, unless a subclass is not going to use
#: serialization.
self.serialize = serializer

#: A custom serialization function for linkage objects.
self.serialize_relationship = simple_relationship_serialize

#: A custom deserialization function for primary resources; see
#: :ref:`serialization` for more information.
#:
#: Use our default deserializer if none is specified.
self.deserialize = deserializer or DefaultDeserializer(session, model)
#: This should not be ``None``, unless a subclass is not going to use
#: deserialization.
self.deserialize = deserializer

#: The tuple of exceptions that are expected to be raised during
#: validation when creating or updating a model.
Expand Down
41 changes: 12 additions & 29 deletions flask_restless/views/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
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
Expand All @@ -49,20 +51,13 @@ class API(APIBase):
superclass. In addition to those described below, this constructor also
accepts all the keyword arguments of the constructor of the superclass.
`page_size`, `max_page_size`, `serializer`, `deserializer`, `includes`, and
`allow_client_generated_ids` are as described in
:meth:`APIManager.create_api`.
`page_size`, `max_page_size`, `serializer`, `deserializer`, and
`includes` are as described in :meth:`APIManager.create_api`.
"""

def __init__(self, session, model, allow_client_generated_ids=False, *args,
**kw):
super(API, self).__init__(session, model, *args, **kw)

#: Whether this API allows the client to specify the ID for the
#: resource to create; for more information, see
#: :ref:`clientids`.
self.allow_client_generated_ids = allow_client_generated_ids
def __init__(self, *args, **kw):
super(API, self).__init__(*args, **kw)

#: Whether any side-effect changes are made to the SQLAlchemy
#: model on updates.
Expand Down Expand Up @@ -384,30 +379,18 @@ def post(self):
# apply any preprocessors to the POST arguments
for preprocessor in self.preprocessors['POST_RESOURCE']:
preprocessor(data=data)
if 'data' not in data:
detail = 'Resource must have a "data" key'
return error_response(400, detail=detail)
data = data['data']
# Convert the dictionary representation into an instance of the
# model.
#
# TODO Should these three initial type and ID checks go in the
# deserializer?
if 'type' not in data:
detail = 'Must specify correct data type'
return error_response(400, detail=detail)
if 'id' in data and not self.allow_client_generated_ids:
detail = 'Server does not allow client-generated IDS'
return error_response(403, detail=detail)
type_ = data.pop('type')
if type_ != self.collection_name:
message = ('Type must be {0}, not'
' {1}').format(self.collection_name, type_)
return error_response(409, detail=message)
try:
instance = self.deserialize(data)
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)
except DeserializationException as exception:
detail = exception.message()
return error_response(400, cause=exception, detail=detail)
Expand Down
8 changes: 4 additions & 4 deletions tests/test_creating.py
Original file line number Diff line number Diff line change
Expand Up @@ -569,9 +569,9 @@ def serializer(instance, *args, **kw):
result['attributes']['foo'] = temp.pop()
return result

def deserializer(data, *args, **kw):
def deserializer(document, *args, **kw):
# Move the attributes up to the top-level object.
data.update(data.pop('attributes', {}))
data = document['data']['attributes']
temp.append(data.pop('foo'))
instance = self.Person(**data)
return instance
Expand Down Expand Up @@ -758,7 +758,7 @@ def test_to_one_relationship_conflicting_type(self):
response = self.app.post('/api/article', data=dumps(data))
keywords = ['deserialize', 'expected', 'type', '"person"', '"article"',
'linkage object', 'relationship', '"author"']
check_sole_error(response, 400, keywords)
check_sole_error(response, 409, keywords)

def test_to_many_relationship_missing_id(self):
"""Tests that the server rejects a request to create a resource
Expand Down Expand Up @@ -840,7 +840,7 @@ def test_to_many_relationship_conflicting_type(self):
response = self.app.post('/api/person', data=dumps(data))
keywords = ['deserialize', 'expected', 'type', '"article"', '"person"',
'linkage object', 'relationship', '"articles"']
check_sole_error(response, 400, keywords)
check_sole_error(response, 409, keywords)


class TestProcessors(ManagerTestBase):
Expand Down

0 comments on commit dd4c0d7

Please sign in to comment.