From dd4c0d75981f78164af1e4d6a526986ac3f15f48 Mon Sep 17 00:00:00 2001 From: Jeffrey Finkelstein Date: Sun, 13 Mar 2016 14:25:19 -0400 Subject: [PATCH] Deserialier now accepts full JSON API document. 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. --- docs/api.rst | 23 +++++++++ docs/customizing.rst | 32 ++++++++++--- flask_restless/manager.py | 7 ++- flask_restless/serialization.py | 80 ++++++++++++++++++++++++------- flask_restless/views/base.py | 12 +++-- flask_restless/views/resources.py | 41 +++++----------- tests/test_creating.py | 8 ++-- 7 files changed, 137 insertions(+), 66 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index c1ad043a..aa7ffd3f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -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 @@ -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) @@ -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 diff --git a/docs/customizing.rst b/docs/customizing.rst index 666262cf..4b051d20 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -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:: diff --git a/flask_restless/manager.py b/flask_restless/manager.py index 6855a1b3..5bce878b 100644 --- a/flask_restless/manager.py +++ b/flask_restless/manager.py @@ -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_, @@ -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 diff --git a/flask_restless/serialization.py b/flask_restless/serialization.py index 19eb2496..7f1ba3bb 100644 --- a/flask_restless/serialization.py +++ b/flask_restless/serialization.py @@ -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 @@ -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. @@ -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. @@ -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): @@ -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): @@ -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.** @@ -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: diff --git a/flask_restless/views/base.py b/flask_restless/views/base.py index 81f50917..092fa289 100644 --- a/flask_restless/views/base.py +++ b/flask_restless/views/base.py @@ -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) @@ -1149,8 +1149,9 @@ 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 @@ -1158,8 +1159,9 @@ def __init__(self, session, model, preprocessors=None, postprocessors=None, #: 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. diff --git a/flask_restless/views/resources.py b/flask_restless/views/resources.py index 0a905c0c..29f48d20 100644 --- a/flask_restless/views/resources.py +++ b/flask_restless/views/resources.py @@ -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 @@ -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. @@ -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) diff --git a/tests/test_creating.py b/tests/test_creating.py index 0d4c622f..b8840593 100644 --- a/tests/test_creating.py +++ b/tests/test_creating.py @@ -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 @@ -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 @@ -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):