From b362b1e75e82ae71237ad3791a8e7d6fdaaa5078 Mon Sep 17 00:00:00 2001 From: eitanp82 Date: Sun, 3 Feb 2019 13:32:37 +0200 Subject: [PATCH 1/6] Add support on customizable media type handling for resources. --- README.md | 8 +- docs/guide/content-types.rst | 558 +++++++++++++++++++++++++++++- docs/reference/graceful.media.rst | 25 ++ docs/reference/modules.rst | 1 + requirements-tests.txt | 1 + src/graceful/media/__init__.py | 1 + src/graceful/media/base.py | 99 ++++++ src/graceful/media/handlers.py | 146 ++++++++ src/graceful/media/json.py | 81 +++++ src/graceful/resources/base.py | 47 +-- tests/test_media_handlers.py | 344 ++++++++++++++++++ 11 files changed, 1276 insertions(+), 35 deletions(-) create mode 100644 docs/reference/graceful.media.rst create mode 100644 src/graceful/media/__init__.py create mode 100644 src/graceful/media/base.py create mode 100644 src/graceful/media/handlers.py create mode 100644 src/graceful/media/json.py create mode 100644 tests/test_media_handlers.py diff --git a/README.md b/README.md index cd75b64..89f5f5c 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ And you're ready to query it (here with awesome [httpie](http://httpie.org) tool): ``` -$ http localhost:8888/v0/cats/?breed=saimese +$ http localhost:8888/v1/cats/?breed=saimese HTTP/1.1 200 OK Connection: close Date: Tue, 16 Jun 2015 08:43:05 GMT @@ -155,7 +155,7 @@ content-type: application/json Or access API description issuing `OPTIONS` request: ``` -$ http OPTIONS localhost:8888/v0/cats +$ http OPTIONS localhost:8888/v1/cats HTTP/1.1 200 OK Connection: close Date: Tue, 16 Jun 2015 08:40:00 GMT @@ -202,14 +202,14 @@ content-type: application/json }, "indent": { "default": "0", - "details": "JSON output indentation. Set to 0 if output should not be formated.", + "details": "JSON output indentation. Set to 0 if output should not be formatted.", "label": null, "required": false, "spec": null, "type": "integer" } }, - "path": "/v0/cats", + "path": "/v1/cats", "type": "list" } ``` diff --git a/docs/guide/content-types.rst b/docs/guide/content-types.rst index f36cfb5..e920902 100644 --- a/docs/guide/content-types.rst +++ b/docs/guide/content-types.rst @@ -1,12 +1,554 @@ Content types ------------- -graceful currently talks only JSON. If you want to support other -content-types then the only way is to override -:meth:`BaseResource.make_body`, -:meth:`BaseResource.require_representation` and optionally -:meth:`BaseResource.on_options` etc. methods. Suggested way would be do -create a class mixin that can be added to every of your resources but ideally -it would be great if someone contributed code that adds reasonable content -negotiation and pluggable content-type serialization. +``graceful`` allows for easy and customizable internet media type handling. +By default ``graceful`` only enables a single JSON handler. +However, additional handlers can be configured through the ``media_handler`` +attribute on a specified resource. + +Here are some resources that be used in the following examples: + +.. code-block:: python + + from operator import itemgetter + + from graceful.serializers import BaseSerializer + from graceful.fields import IntField, RawField + from graceful.parameters import StringParam + from graceful.resources.generic import RetrieveAPI, PaginatedListAPI + + + CATS_STORAGE = [ + {'id': 0, 'name': 'kitty', 'breed': 'siamese'}, + {'id': 1, 'name': 'lucie', 'breed': 'maine coon'}, + {'id': 2, 'name': 'molly', 'breed': 'sphynx'} + ] + + + class CatSerializer(BaseSerializer): + id = IntField('cat identification number', read_only=True) + name = RawField('cat name') + breed = RawField('official breed name') + + + class BaseCatResource(RetrieveAPI, with_context=True): + """Single cat identified by its id.""" + serializer = CatSerializer() + + def get_cat(self, cat_id): + for cat in CATS_STORAGE: + if cat['id'] == cat_id: + return cat + else: + raise falcon.HTTPNotFound + + def retrieve(self, params, meta, context, *, cat_id, **kwargs): + return self.get_cat(cat_id) + + + class BaseCatListResource(PaginatedListAPI, with_context=True): + """List of all cats in our API.""" + serializer = CatSerializer() + + breed = StringParam('set this param to filter cats by breed') + + @classmethod + def get_next_cat_id(cls): + try: + return max(CATS_STORAGE, key=itemgetter('id'))['id'] + 1 + except (ValueError, KeyError): + return 0 + + def create(self, params, meta, validated, context, **kwargs): + validated['id'] = self.get_next_cat_id() + CATS_STORAGE.append(validated) + return validated + + def list(self, params, meta, context, **kwargs): + if 'breed' in params: + filtered = [ + cat for cat in CATS_STORAGE + if cat['breed'] == params['breed'] + ] + return filtered + else: + return CATS_STORAGE + + +Custom media handler +~~~~~~~~~~~~~~~~~~~~ + +Custom media handler can be created by subclassing of :class:`BaseMediaHandler` +class and implementing of two method handlers: + +* ``.deserialize(stream, content_type, content_length)``: returns deserialized Python object from a stream +* ``.serialize(media, content_type)``: returns serialized media object + +And also implementing of a property that defines the media type of the handler: + +* ``media_type``: returns the media type to use when deserializing a response + +Lets say you want to write a resource that sends and receives YAML documents. +You can easily do this by creating a new media handler class that represents +a media-type of ``application/yaml`` and can process that data. + +Here is an example of how this can be done: + +.. code-block:: python + + import falcon + import yaml + + from graceful.media.base import BaseMediaHandler + + + class YAMLHandler(BaseMediaHandler): + """YAML media handler.""" + + def deserialize(self, stream, content_type, content_length, **kwargs): + try: + return yaml.load(stream.read(content_length or 0)) + except yaml.error.YAMLError as err: + raise falcon.HTTPBadRequest( + title='Invalid YAML', + description='Could not parse YAML body - {}'.format(err)) + + def serialize(self, media, content_type, indent=0, **kwargs): + return yaml.dump(media, indent=indent or None, **kwargs) + + @property + def media_type(self): + # 'application/yaml' + return falcon.MEDIA_YAML + +.. note:: + This handler requires the `pyyaml `_ + package, which must be installed in addition to ``graceful`` from PyPI: + + .. code:: + + $ pip install pyyaml + +Example usage: + +.. code-block:: python + + class CatResource(BaseCatResource): + media_handler = YAMLHandler() + + + class CatListResource(BaseCatListResource): + media_handler = YAMLHandler() + + + api = falcon.API() + api.add_route('/v1/cats/{cat_id}', CatResource()) + api.add_route('/v1/cats/', CatListResource()) + +Querying: + +.. code-block:: yaml + + $ http localhost:8888/v1/cats/0 + HTTP/1.1 200 OK + Content-Length: 74 + Content-Type: application/yaml + Date: Fri, 01 Feb 2019 09:07:29 GMT + Server: waitress + + content: {breed: siamese, id: 0, name: kitty} + meta: + params: {indent: 0} + + $ http localhost:8888/v1/cats/?breed=sphynx + HTTP/1.1 200 OK + Content-Length: 90 + Content-Type: application/yaml + Date: Fri, 01 Feb 2019 09:07:53 GMT + Server: waitress + + content: + - {breed: sphynx, id: 2, name: molly} + meta: + params: {breed: sphynx, indent: 0} + +Or access API description issuing ``OPTIONS`` request: + +.. code-block:: yaml + + $ http OPTIONS localhost:8888/v1/cats + HTTP/1.1 200 OK + Allow: GET, POST, PATCH, OPTIONS + Content-Length: 1025 + Content-Type: application/yaml + Date: Fri, 01 Feb 2019 09:08:05 GMT + Server: waitress + + details: This resource does not have description yet + fields: !!python/object/apply:collections.OrderedDict + - - - id + - {allow_null: false, details: cat identification number, label: null, read_only: true, + spec: null, type: int, write_only: false} + - - name + - {allow_null: false, details: cat name, label: null, read_only: false, spec: null, + type: raw, write_only: false} + - - breed + - {allow_null: false, details: official breed name, label: null, read_only: false, + spec: null, type: raw, write_only: false} + methods: [GET, POST, PATCH, OPTIONS] + name: CatListResource + params: !!python/object/apply:collections.OrderedDict + - - - indent + - {default: '0', details: JSON output indentation. Set to 0 if output should not + be formatted., label: null, many: false, required: false, spec: null, type: integer} + - - breed + - {default: null, details: set this param to filter cats by breed, label: null, + many: false, required: false, spec: null, type: string} + path: /v1/cats + type: list + +Adding a new cat named `misty` through YAML document: + +.. code-block:: yaml + + $ http POST localhost:8888/v1/cats name="misty" breed="siamese" Content-Type:application/yaml + HTTP/1.1 201 Created + Content-Length: 74 + Content-Type: application/yaml + Date: Fri, 01 Feb 2019 09:10:46 GMT + Server: waitress + + content: {breed: siamese, id: 3, name: misty} + meta: + params: {indent: 0} + + $ http localhost:8888/v1/cats/?breed=siamese + HTTP/1.1 200 OK + Content-Length: 131 + Content-Type: application/yaml + Date: Fri, 01 Feb 2019 09:12:11 GMT + Server: waitress + + content: + - {breed: siamese, id: 0, name: kitty} + - {breed: siamese, id: 3, name: misty} + meta: + params: {breed: siamese, indent: 0} + +However, JSON document is not allowed in this particular case: + +.. code-block:: console + + $ http POST localhost:8888/v1/cats name="daisy" breed="sphynx" + HTTP/1.1 415 Unsupported Media Type + Content-Length: 143 + Content-Type: application/json; charset=UTF-8 + Date: Fri, 01 Feb 2019 09:13:42 GMT + Server: waitress + Vary: Accept + + { + "description": "'application/json' is an unsupported media type, supported media types: 'application/yaml'", + "title": "Unsupported media type" + } + +In general, a media handler can process data of its default internet media type. +However, If a media handler can process the request body of additional media +types, It is possible to configure it through the ``extra_media_types`` parameter. + +Here is an example of how this can be done: + +.. code-block:: python + + class CatListResource(BaseCatListResource): + media_handler = YAMLHandler(extra_media_types=['application/json']) + + + api = falcon.API() + api.add_route('/v1/cats/', CatListResource()) + + +Adding a new cat named `misty` through YAML document: + +.. code-block:: yaml + + $ http POST localhost:8888/v1/cats name="misty" breed="siamese" Content-Type:application/yaml + HTTP/1.1 201 Created + Content-Length: 74 + Content-Type: application/yaml + Date: Fri, 01 Feb 2019 09:20:03 GMT + Server: waitress + + content: {breed: siamese, id: 3, name: misty} + meta: + params: {indent: 0} + + +Adding a new cat named `daisy` through JSON document: + +.. code-block:: yaml + + $ http POST localhost:8888/v1/cats name="daisy" breed="sphynx" + HTTP/1.1 201 Created + Content-Length: 73 + Content-Type: application/yaml + Date: Fri, 01 Feb 2019 09:20:25 GMT + Server: waitress + + content: {breed: sphynx, id: 4, name: daisy} + meta: + params: {indent: 0} + + +Custom JSON handler type +~~~~~~~~~~~~~~~~~~~~~~~~ + +The default JSON media handler using Python’s json module. +If you want to use on other JSON libraries such as ``ujson``, +You can create a custom JSON media handler for that purpose. + +Custom JSON media handler can be created by subclassing of :class:`JSONHandler` +class and implementing of two class method handlers: + +* ``.dumps(obj, indent=0)``: returns serialized JSON formatted string +* ``.loads(s)``: returns deserialized Python object from a JSON document + + +Here is an example of how this can be done: + +.. code-block:: python + + import ujson + + from graceful.media.json import JSONHandler + + + class UltraJSONHandler(JSONHandler): + """Ultra JSON media handler.""" + + @classmethod + def dumps(cls, obj, *args, indent=0, **kwargs): + return ujson.dumps(obj, *args, indent=indent, **kwargs) + + @classmethod + def loads(cls, s, *args, **kwargs): + return ujson.loads(s.decode('utf-8'), *args, **kwargs) + +Alternatively, subclassing of :class:`BaseMediaHandler`: + +.. code-block:: python + + import ujson + + from graceful.media.base import BaseMediaHandler + + + class UltraJSONHandler(BaseMediaHandler): + """Ultra JSON media handler.""" + + def deserialize(self, stream, content_type, content_length, **kwargs): + try: + return ujson.loads(stream.read(content_length or 0), **kwargs) + except ValueError as err: + raise falcon.HTTPBadRequest( + title='Invalid JSON', + description='Could not parse JSON body - {}'.format(err)) + + def serialize(self, media, content_type, indent=0, **kwargs): + return ujson.dumps(media, indent=indent, **kwargs) + + @property + def media_type(self): + return 'application/json' + +.. note:: + This handler requires the `ujson `_ + package, which must be installed in addition to ``graceful`` from PyPI: + + .. code:: + + $ pip install ujson + +Media handlers management +~~~~~~~~~~~~~~~~~~~~~~~~~ + +The purpose of :class:`MediaHandlers` class is to be a single handler that +manages internet media type handlers. + + +Here is an example of how this can be used: + +.. code-block:: python + + from graceful.media.handlers import MediaHandlers + + + class CatListResource(BaseCatListResource): + media_handler = MediaHandlers( + default_media_type='application/json', + handlers = { + 'application/json': UltraJSONHandler(), + 'application/yaml': YAMLHandler() + } + ) + + + api = falcon.API() + api.add_route('/v1/cats/', CatListResource()) + +Adding a new cat named `misty` through YAML document: + +.. code-block:: console + + $ http POST localhost:8888/v1/cats name="misty" breed="siamese" Content-Type:application/yaml + HTTP/1.1 201 Created + Content-Length: 84 + Content-Type: application/json + Date: Fri, 01 Feb 2019 12:37:59 GMT + Server: waitress + + { + "content": { + "breed": "siamese", + "id": 3, + "name": "misty" + }, + "meta": { + "params": { + "indent": 0 + } + } + } + +Adding a new cat named `daisy` through JSON document: + +.. code-block:: console + + $ http POST localhost:8888/v1/cats name="daisy" breed="sphynx" + HTTP/1.1 201 Created + Content-Length: 84 + Content-Type: application/json + Date: Fri, 01 Feb 2019 12:38:35 GMT + Server: waitress + + { + "content": { + "breed": "sphynx", + "id": 4, + "name": "daisy" + }, + "meta": { + "params": { + "indent": 0 + } + } + } + +By default, a responder always use the default internet media type +which is ``application/json`` in our example: + +.. code-block:: console + + $ http localhost:8888/v1/cats?breed=siamese Content-Type:application/yaml + HTTP/1.1 200 OK + Content-Length: 104 + Content-Type: application/json + Date: Sat, 02 Feb 2019 16:49:38 GMT + Server: waitress + + { + "content": [ + { + "breed": "siamese", + "id": 0, + "name": "kitty" + } + ], + "meta": { + "params": { + "breed": "siamese", + "indent": 0 + } + } + } + + $ http localhost:8888/v1/cats?breed=siamese + HTTP/1.1 200 OK + Content-Length: 104 + Content-Type: application/json + Date: Sat, 02 Feb 2019 16:49:47 GMT + Server: waitress + + { + "content": [ + { + "breed": "siamese", + "id": 0, + "name": "kitty" + } + ], + "meta": { + "params": { + "breed": "siamese", + "indent": 0 + } + } + } + +If you do need full negotiation, it is very easy to do it by using middleware. + +Here is an example of how this can be done: + +.. code-block:: python + + class NegotiationMiddleware(object): + def process_request(self, req, resp): + resp.content_type = req.content_type + + + api = falcon.API(middleware=NegotiationMiddleware()) + api.add_route('/v1/cats/', CatListResource()) + +Querying through YAML: + +.. code-block:: yaml + + $ http localhost:8888/v1/cats?breed=siamese Content-Type:application/yaml + HTTP/1.1 200 OK + Content-Length: 92 + Content-Type: application/yaml + Date: Sat, 02 Feb 2019 17:00:01 GMT + Server: waitress + + content: + - {breed: siamese, id: 0, name: kitty} + meta: + params: {breed: siamese, indent: 0} + +Querying through JSON: + +.. code-block:: console + + $ http localhost:8888/v1/cats?breed=siamese + HTTP/1.1 200 OK + Content-Length: 104 + Content-Type: application/json + Date: Sat, 02 Feb 2019 17:00:10 GMT + Server: waitress + + { + "content": [ + { + "breed": "siamese", + "id": 0, + "name": "kitty" + } + ], + "meta": { + "params": { + "breed": "siamese", + "indent": 0 + } + } + } diff --git a/docs/reference/graceful.media.rst b/docs/reference/graceful.media.rst new file mode 100644 index 0000000..ad3eb08 --- /dev/null +++ b/docs/reference/graceful.media.rst @@ -0,0 +1,25 @@ +graceful.media package +====================== + +graceful.media.base module +-------------------------- + +.. automodule:: graceful.media.base + :members: + :undoc-members: + + +graceful.media.json module +-------------------------- + +.. automodule:: graceful.media.json + :members: + :undoc-members: + + +graceful.media.handlers module +-------------------------------- + +.. automodule:: graceful.media.handlers + :members: + :undoc-members: diff --git a/docs/reference/modules.rst b/docs/reference/modules.rst index 5e7af77..5b66a3d 100644 --- a/docs/reference/modules.rst +++ b/docs/reference/modules.rst @@ -9,3 +9,4 @@ API reference graceful graceful.resources + graceful.media diff --git a/requirements-tests.txt b/requirements-tests.txt index 287c92b..6c97383 100644 --- a/requirements-tests.txt +++ b/requirements-tests.txt @@ -1 +1,2 @@ pytest<3.3.0 +pytest-mock diff --git a/src/graceful/media/__init__.py b/src/graceful/media/__init__.py new file mode 100644 index 0000000..b47e36c --- /dev/null +++ b/src/graceful/media/__init__.py @@ -0,0 +1 @@ +"""Subpackage that provides all media handler classes.""" diff --git a/src/graceful/media/base.py b/src/graceful/media/base.py new file mode 100644 index 0000000..9e0a740 --- /dev/null +++ b/src/graceful/media/base.py @@ -0,0 +1,99 @@ +from abc import ABCMeta, abstractmethod + +import falcon + + +class BaseMediaHandler(metaclass=ABCMeta): + """An abstract base class for an internet media type handler. + + Args: + extra_media_types (list): An extra media types to support when + deserialize the body stream of request objects + + Attributes: + allowed_media_types (set): All media types supported for deserialization + + """ + + def __init__(self, extra_media_types=None): + """The __init__ method documented in the class level.""" + extra_media_types = extra_media_types or [] + self.allowed_media_types = set([self.media_type] + extra_media_types) + + @abstractmethod + def deserialize(self, stream, content_type, content_length, **kwargs): + """Deserialize the body stream from a :class:`falcon.Request`. + + Args: + stream (io.BytesIO): Input data to deserialize + content_type (str): Type of request content + content_length (int): Length of request content + + Returns: + object: A deserialized object. + + Raises: + falcon.HTTPBadRequest: An error occurred on attempt to + deserialization an invalid stream. + + """ + raise NotImplementedError + + @abstractmethod + def serialize(self, media, content_type, **kwargs): + """Serialize the media object for a :class:`falcon.Response`. + + Args: + media (object): A Python data structure to serialize + content_type (str): Type of response content + + Returns: + A serialized (``str`` or ``bytes``) representation of ``media``. + + """ + raise NotImplementedError + + def handle_response(self, resp, *, media, **kwargs): + """Process a single :class:`falcon.Response` object. + + Args: + resp (falcon.Response): The response object to process + media (object): A Python data structure to serialize + + """ + # sets the Content-Type header + resp.content_type = self.media_type + data = self.serialize(media, resp.content_type, **kwargs) + # a small performance gain by assigning bytes directly to resp.data + if isinstance(data, bytes): + resp.data = data + else: + resp.body = data + + def handle_request(self, req, *, content_type=None, **kwargs): + """Process a single :class:`falcon.Request` object. + + Args: + req (falcon.Request): The request object to process + content_type (str): Type of request content + + Raises: + falcon.HTTPUnsupportedMediaType: If `content_type` is not supported + + """ + content_type = content_type or req.content_type + if content_type in self.allowed_media_types: + req._media = self.deserialize( + req.stream, content_type, req.content_length, **kwargs) + else: + allowed = ', '.join("'{}'".format(media_type) + for media_type in self.allowed_media_types) + raise falcon.HTTPUnsupportedMediaType( + description="'{}' is an unsupported media type, supported " + "media types: {}".format(content_type, allowed)) + + @property + @abstractmethod + def media_type(self): + """The media type to use when deserializing a response.""" + raise NotImplementedError diff --git a/src/graceful/media/handlers.py b/src/graceful/media/handlers.py new file mode 100644 index 0000000..21ecabb --- /dev/null +++ b/src/graceful/media/handlers.py @@ -0,0 +1,146 @@ +import falcon +import mimeparse + +from graceful.media.base import BaseMediaHandler +from graceful.media.json import JSONHandler + + +class MediaHandlers(BaseMediaHandler): + """A media handler that manages internet media type handlers. + + Args: + default_media_type (str): The default internet media type to use when + deserializing a response + handlers (dict): A dict-like object that allows you to configure the + media types that you would like to handle + + Attributes: + default_media_type (str): The default internet media type to use when + deserializing a response + handlers (dict): A dict-like object that allows you to configure the + media types that you would like to handle. By default, a handler is + provided for the ``application/json`` media type. + """ + + def __init__(self, default_media_type='application/json', handlers=None): + self.default_media_type = default_media_type + self.handlers = handlers or { + 'application/json': JSONHandler(), + 'application/json; charset=UTF-8': JSONHandler() + } + if handlers is not None: + extra_handlers = { + media_type: handler + for handler in handlers.values() + for media_type in handler.allowed_media_types + if media_type not in self.handlers + } + self.handlers.update(extra_handlers) + if self.default_media_type not in self.handlers: + raise ValueError("no handler for default media type '{}'".format( + default_media_type)) + super().__init__(extra_media_types=list(self.handlers)) + + def deserialize(self, stream, content_type, content_length, handler=None): + """Deserialize the body stream from a :class:`falcon.Request`. + + Args: + stream (io.BytesIO): Input data to deserialize + content_type (str): Type of request content + content_length (int): Length of request content + handler (BaseMediaHandler): A media handler for deserialization + + Returns: + object: A deserialized object. + + Raises: + falcon.HTTPBadRequest: An error occurred on attempt to + deserialization an invalid stream. + + """ + handler = handler or self.lookup_handler(content_type) + return handler.deserialize(stream, content_type, content_length) + + def serialize(self, media, content_type, handler=None): + """Serialize the media object for a :class:`falcon.Response`. + + Args: + media (object): A Python data structure to serialize + content_type (str): Type of response content + handler (BaseMediaHandler): A media handler for serialization + + Returns: + A serialized (a ``str`` or ``bytes`` instance) representation from + the `media` object. + + """ + handler = handler or self.lookup_handler(content_type) + return handler.serialize(media, content_type) + + def handle_response(self, resp, *, media, **kwargs): + """Process a single :class:`falcon.Response` object. + + Args: + resp (falcon.Response): The response object to process + media (object): A Python data structure to serialize + + """ + content_type = resp.content_type or self.media_type + default_media_type = resp.options.default_media_type + handler = self.lookup_handler(content_type, default_media_type) + super().handle_response(resp, media=media, handler=handler) + resp.content_type = handler.media_type + + def handle_request(self, req, *, content_type=None, **kwargs): + """Process a single :class:`falcon.Request` object. + + Args: + req (falcon.Request): The request object to process + content_type (str): Type of request content + + Raises: + falcon.HTTPUnsupportedMediaType: If `content_type` is not supported + + """ + content_type = content_type or req.content_type + default_media_type = req.options.default_media_type + handler = self.lookup_handler(content_type, default_media_type) + super().handle_request(req, content_type=content_type, handler=handler) + + def lookup_handler(self, media_type, default_media_type=None): + """Lookup media handler by media type. + + Args: + media_type (str): A media type of the registered media handler + default_media_type (str): The default media type to use when + `media_type` is not specified + + Returns: + BaseMediaHandler: A media handler. + + Raises: + falcon.HTTPUnsupportedMediaType: If `content_type` is not supported + + """ + if media_type == '*/*' or not media_type: + media_type = default_media_type or self.media_type + handler = self.handlers.get(media_type, None) + if handler is None: + try: + resolved = mimeparse.best_match(self.handlers, media_type) + assert not resolved + handler = self.handlers[resolved] + except (AssertionError, KeyError, ValueError): + allowed = ', '.join("'{}'".format(media_type) + for media_type in self.allowed_media_types) + raise falcon.HTTPUnsupportedMediaType( + description="'{}' is an unsupported media type, supported " + "media types: {}".format(media_type, allowed)) + else: + self.handlers[media_type] = handler + return handler + + @property + def media_type(self): + """The default media type to use when deserializing a response.""" + return self.default_media_type diff --git a/src/graceful/media/json.py b/src/graceful/media/json.py new file mode 100644 index 0000000..4dafe56 --- /dev/null +++ b/src/graceful/media/json.py @@ -0,0 +1,81 @@ +import json +import falcon + +from graceful.media.base import BaseMediaHandler + + +class JSONHandler(BaseMediaHandler): + """JSON media handler.""" + + @classmethod + def dumps(cls, obj, *args, indent=0, **kwargs): + """Serialize ``obj`` to a JSON formatted string. + + Args: + obj (object): A Python data structure to serialize + indent (int): An indention level (“pretty-printing”) + + Returns: + str: A JSON formatted string representation of ``obj``. + + """ + return json.dumps(obj, *args, indent=indent or None, **kwargs) + + @classmethod + def loads(cls, s, *args, **kwargs): + """Deserialize ``s`` to a Python object. + + Args: + s (bytes): Input bytes containing JSON document to deserialize + + Returns: + object: Python representation of ``s``. + + Raises: + ValueError: If the data being deserialized is not a valid JSON + document + + """ + return json.loads(s.decode('utf-8'), *args, **kwargs) + + def deserialize(self, stream, content_type, content_length, **kwargs): + """Deserialize the body stream from a :class:`falcon.Request`. + + Args: + stream (io.BytesIO): Input data to deserialize + content_type (str): Type of request content + content_length (int): Length of request content + + Returns: + object: A deserialized object. + + Raises: + falcon.HTTPBadRequest: An error occurred on attempt to + deserialization an invalid stream + + """ + try: + return self.loads(stream.read(content_length or 0), **kwargs) + except ValueError as err: + raise falcon.HTTPBadRequest( + title='Invalid JSON', + description='Could not parse JSON body - {}'.format(err)) + + def serialize(self, media, content_type, indent=0, **kwargs): + """Serialize the media object for a :class:`falcon.Response`. + + Args: + media (object): A Python data structure to serialize + content_type (str): Type of response content + indent (int): An indention level (“pretty-printing”) + + Returns: + A serialized (``str`` or ``bytes``) representation of ``media``. + + """ + return self.dumps(media, indent=indent, **kwargs) + + @property + def media_type(self): + """The media type to use when deserializing a response.""" + return 'application/json' diff --git a/src/graceful/resources/base.py b/src/graceful/resources/base.py index 5364c7a..a9dc3c6 100644 --- a/src/graceful/resources/base.py +++ b/src/graceful/resources/base.py @@ -1,4 +1,3 @@ -import json import inspect from collections import OrderedDict from warnings import warn @@ -9,6 +8,7 @@ from graceful.parameters import BaseParam, IntParam from graceful.errors import DeserializationError, ValidationError +from graceful.media.json import JSONHandler class MetaResource(type): @@ -36,9 +36,9 @@ def _get_params(mcs, bases, namespace): """Create params dictionary to be used in resource class namespace. Pop all parameter objects from attributes dict (namespace) - and store them under _params_storage_key atrribute. + and store them under _params_storage_key attribute. Also collect all params from base classes in order that ensures - params can be overriden. + params can be overridden. Args: bases: all base classes of created resource class @@ -85,7 +85,7 @@ def __init__(cls, name, bases, namespace, **kwargs): class BaseResource(metaclass=MetaResource): - """Base resouce class with core param and response functionality. + """Base resource class with core param and response functionality. This base class handles resource responses, parameter deserialization, and validation of request included representations if serializer is @@ -101,7 +101,7 @@ class MyResource(BaseResource, with_context=True): ... The ``with_context`` argument tells if resource modification methods - (metods injected with mixins - list/create/update/etc.) should accept + (methods injected with mixins - list/create/update/etc.) should accept the ``context`` argument in their signatures. For more details see :ref:`guide-context-aware-resources` section of documentation. The default value for ``with_context`` class keyword argument is ``False``. @@ -109,11 +109,15 @@ class MyResource(BaseResource, with_context=True): .. versionchanged:: 0.3.0 Added the ``with_context`` keyword argument. + Note: + The ``indent`` parameter may be used only on a supported media handler + such as JSON or YAML, otherwise it should be ignored by the handler. + """ indent = IntParam( """ - JSON output indentation. Set to 0 if output should not be formated. + JSON output indentation. Set to 0 if output should not be formatted. """, default='0' ) @@ -122,6 +126,10 @@ class MyResource(BaseResource, with_context=True): #: validate resource representations. serializer = None + #: Instance of media handler class used to serialize response + #: objects and to deserialize request objects. + media_handler = JSONHandler() + def __new__(cls, *args, **kwargs): """Do some sanity checks before resource instance initialization.""" instance = super().__new__(cls) @@ -151,7 +159,7 @@ def params(self): return getattr(self, self.__class__._params_storage_key) def make_body(self, resp, params, meta, content): - """Construct response body in ``resp`` object using JSON serialization. + """Construct response body/data in ``resp`` object using media handler. Args: resp (falcon.Response): response object where to include @@ -170,11 +178,9 @@ def make_body(self, resp, params, meta, content): 'meta': meta, 'content': content } - resp.content_type = 'application/json' - resp.body = json.dumps( - response, - indent=params['indent'] or None if 'indent' in params else None - ) + + self.media_handler.handle_response( + resp, media=response, indent=params.get('indent', 0)) def allowed_methods(self): """Return list of allowed HTTP methods on this resource. @@ -248,7 +254,7 @@ def describe(req, resp, **kwargs): return description def on_options(self, req, resp, **kwargs): - """Respond with JSON formatted resource description on OPTIONS request. + """Respond with media formatted resource description on OPTIONS request. Args: req (falcon.Request): Optional request object. Defaults to None. @@ -265,8 +271,8 @@ def on_options(self, req, resp, **kwargs): allowed HTTP methods. """ resp.set_header('Allow', ', '.join(self.allowed_methods())) - resp.body = json.dumps(self.describe(req, resp)) - resp.content_type = 'application/json' + self.media_handler.handle_response( + resp, media=self.describe(req, resp)) def require_params(self, req): """Require all defined parameters from request query string. @@ -364,7 +370,7 @@ def require_representation(self, req): allowed content-encoding handler to decode content body. Note: - Currently only JSON is allowed as content type. + By default, only JSON is allowed as content type. Args: req (falcon.Request): request object @@ -383,13 +389,8 @@ def require_representation(self, req): ) ) - if content_type == 'application/json': - body = req.stream.read() - return json.loads(body.decode('utf-8')) - else: - raise falcon.HTTPUnsupportedMediaType( - description="only JSON supported, got: {}".format(content_type) - ) + self.media_handler.handle_request(req, content_type=content_type) + return req.media def require_validated(self, req, partial=False, bulk=False): """Require fully validated internal object dictionary. diff --git a/tests/test_media_handlers.py b/tests/test_media_handlers.py new file mode 100644 index 0000000..5298b36 --- /dev/null +++ b/tests/test_media_handlers.py @@ -0,0 +1,344 @@ +import copy +import io +import json + +import pytest + +import falcon +from falcon.testing import create_environ + +from graceful.media.base import BaseMediaHandler +from graceful.media.json import JSONHandler +from graceful.media.handlers import MediaHandlers + + +class SimpleMediaHandler(BaseMediaHandler): + def deserialize(self, stream, content_type, content_length): + try: + s = stream.read(content_length or 0) + fp = io.StringIO(s.decode('utf-8') if isinstance(s, bytes) else s) + return json.load(fp) + except ValueError as err: + raise falcon.HTTPBadRequest( + title='Invalid JSON', + description='Could not parse JSON body - {}'.format(err)) + + def serialize(self, media, content_type, **kwargs): + fp = io.StringIO() + json.dump(media, fp) + return fp.getvalue() + + @property + def media_type(self): + return 'application/json' + + +class SimpleJSONHandler(JSONHandler): + """A simple tested media handler.""" + @classmethod + def dumps(cls, obj, *args, indent=0, **kwargs): + fp = io.StringIO() + json.dump(obj, fp, indent=indent or None) + return fp.getvalue() + + @classmethod + def loads(cls, s, *args, **kwargs): + fp = io.StringIO(s.decode('utf-8')) + return json.load(fp) + + +@pytest.fixture(scope='module') +def media(): + return { + 'content': { + 'breed': 'siamese', + 'id': 0, + 'name': 'kitty' + }, + 'meta': { + 'params': { + 'indent': 0 + } + } + } + + +@pytest.fixture +def media_json(): + return 'application/json' + + +@pytest.fixture +def req(media, media_json): + headers = {'Content-Type': media_json} + env = create_environ(body=json.dumps(media), headers=headers) + return falcon.Request(env) + + +@pytest.fixture(params=[ + JSONHandler(), + SimpleJSONHandler(), + SimpleMediaHandler(), + MediaHandlers() +]) +def media_handler(request): + return request.param + + +@pytest.fixture +def json_handler(): + return JSONHandler() + + +@pytest.fixture +def subclass_json_handler(): + return SimpleJSONHandler() + + +@pytest.fixture +def media_handlers(): + return MediaHandlers() + + +def test_abstract_media_handler(): + with pytest.raises(TypeError): + BaseMediaHandler() + + +def test_allowed_media_types(): + handler = SimpleMediaHandler(extra_media_types=['application/yaml']) + assert isinstance(handler.allowed_media_types, set) + assert len(handler.allowed_media_types) == 2 + assert 'application/json' in handler.allowed_media_types + assert 'application/yaml' in handler.allowed_media_types + + +def test_json_handler_media_type(json_handler, media_json): + assert json_handler.media_type == media_json + + +def test_json_handler_deserialize(json_handler, media, media_json): + body = json.dumps(media) + stream = io.BytesIO(body.encode('utf-8')) + assert json_handler.deserialize(stream, media_json, len(body)) == media + + +def test_json_handler_deserialize_invalid_stream(json_handler, media_json): + with pytest.raises(falcon.HTTPBadRequest): + json_handler.deserialize(io.BytesIO(b'{'), media_json, 1) + + +def test_json_handler_serialize(json_handler, media, media_json): + expected = json.dumps(media) + assert json_handler.serialize(media, media_json) == expected + + +@pytest.mark.parametrize('indent', [2, 4]) +def test_json_handler_serialize_indent( + json_handler, mocker, media, media_json, indent): + mocker.patch.object(json, 'dumps', autospec=True) + json_handler.serialize(media, media_json, indent=indent) + json.dumps.assert_called_once_with(media, indent=indent) + + +def test_json_handler_serialize_indent_none( + json_handler, mocker, media, media_json): + mocker.patch.object(json, 'dumps', autospec=True) + json_handler.serialize(media, media_json, indent=0) + json.dumps.assert_called_once_with(media, indent=None) + with pytest.raises(AssertionError): + json.dumps.assert_called_once_with(media, indent=0) + + +def test_subclass_json_handler_media_type(subclass_json_handler, media_json): + assert subclass_json_handler.media_type == media_json + + +def test_subclass_json_dumps(subclass_json_handler): + obj = {'testing': True} + expected = json.dumps(obj) + assert subclass_json_handler.dumps(obj) == expected + + +def test_subclass_json_loads(subclass_json_handler): + s = b'{"testing": true}' + expected = json.loads(s.decode('utf-8')) + assert subclass_json_handler.loads(s) == expected + + +def test_handle_request(media_handler, req, media, media_json): + media_handler.handle_request(req, content_type=media_json) + assert req.media == media + + +def test_handle_request_unsupported_media_type(media_handler, req): + with pytest.raises(falcon.HTTPUnsupportedMediaType): + media_handler.handle_request(req, content_type='nope/json') + + +def test_handle_response(media_handler, resp, media): + media_handler.handle_response(resp, media=media) + assert resp.data or resp.body + assert resp.data or isinstance(resp.body, str) + assert resp.body or isinstance(resp.data, bytes) + + +def test_handle_response_content_type(media_handler, resp, media): + media_handler.handle_response(resp, media=media) + assert resp.content_type == media_handler.media_type + + +def test_handle_response_serialized_string(media_handler, resp, mocker): + serialized = '{"testing": true}' + mocker.patch.object(media_handler, 'serialize', return_value=serialized) + media_handler.handle_response(resp, media={'testing': True}) + assert resp.body == serialized + assert resp.data is None + + +def test_handle_response_serialized_bytes(media_handler, resp, mocker): + serialized = b'{"testing": true}' + mocker.patch.object(media_handler, 'serialize', return_value=serialized) + media_handler.handle_response(resp, media={'testing': True}) + assert resp.data == serialized + assert resp.body is None + + +def test_serialization_process(media_handler, media): + content_type = media_handler.media_type + s = media_handler.serialize(media, content_type) + stream = io.BytesIO(s.encode('utf-8') if isinstance(s, str) else s) + assert media_handler.deserialize(stream, content_type, len(s)) == media + + +def test_media_handlers_default_media_type(media_handlers): + assert media_handlers.media_type == 'application/json' + + +def test_media_handlers_unknown_default_media_type(): + with pytest.raises(ValueError): + handlers = {'application/json': JSONHandler()} + MediaHandlers(default_media_type='nope/json', handlers=handlers) + + +def test_media_handlers_allowed_media_types(media_handlers): + assert isinstance(media_handlers.allowed_media_types, set) + assert len(media_handlers.allowed_media_types) == 2 + expected = {'application/json', 'application/json; charset=UTF-8'} + assert media_handlers.allowed_media_types == expected + + +@pytest.mark.parametrize('media_type', [ + 'application/json', + 'application/json; charset=UTF-8' +]) +def test_media_handlers_lookup(media_handlers, media_type): + handler = media_handlers.lookup_handler(media_type) + assert isinstance(handler, JSONHandler) + + +@pytest.mark.parametrize('media_type', [ + 'application/json', + 'application/json; charset=UTF-8' +]) +def test_media_handlers_lookup_by_default_media_type( + media_handlers, media_type): + handler = media_handlers.lookup_handler('*/*', media_type) + assert isinstance(handler, JSONHandler) + handler = media_handlers.lookup_handler(None, media_type) + assert isinstance(handler, JSONHandler) + + +def test_media_handlers_lookup_unknown_media_type(media_handlers): + with pytest.raises(falcon.HTTPUnsupportedMediaType): + media_handlers.lookup_handler('nope/json') + with pytest.raises(falcon.HTTPUnsupportedMediaType): + media_handlers.lookup_handler('*/*', 'nope/json') + with pytest.raises(falcon.HTTPUnsupportedMediaType): + media_handlers.lookup_handler(None, 'nope/json') + + +@pytest.mark.parametrize('default_media_type', [ + 'application/json', + 'application/yaml' +]) +def test_custom_media_handlers(default_media_type, req, resp, media, mocker): + class FakeYAMLHandler(BaseMediaHandler): + def deserialize(self, stream, content_type, content_length, **kwargs): + try: + return json.loads(stream.read(content_length or 0), **kwargs) + except ValueError as err: + raise falcon.HTTPBadRequest( + title='Invalid YAML', + description='Could not parse YAML body - {}'.format(err)) + + def serialize(self, media, content_type, indent=0, **kwargs): + return json.dumps(media, indent=indent, **kwargs) + + @property + def media_type(self): + return 'application/yaml' + + json_handler = JSONHandler() + yaml_handler = FakeYAMLHandler() + + media_handlers = MediaHandlers( + default_media_type=default_media_type, + handlers={ + 'application/json': json_handler, + 'application/yaml': yaml_handler + } + ) + request_stream = copy.copy(req.stream) + + # testing YAML request handler + assert media_handlers.media_type == default_media_type + assert media_handlers.lookup_handler('application/yaml') is yaml_handler + mocker.patch.object(yaml_handler, 'deserialize') + req.stream = request_stream + req.content_type = 'application/yaml' + media_handlers.handle_request(req) + yaml_handler.deserialize.assert_called_once() + + # testing JSON request handler + assert media_handlers.lookup_handler('application/json') is json_handler + mocker.patch.object(json_handler, 'deserialize') + req.stream = request_stream + req.content_type = 'application/json' + media_handlers.handle_request(req) + json_handler.deserialize.assert_called_once() + + # testing response handler + default_handler = media_handlers.handlers[default_media_type] + mocker.patch.object(default_handler, 'serialize') + media_handlers.handle_response(resp, media=media) + assert resp.content_type == media_handlers.media_type + default_handler.serialize.assert_called_once() + + +def test_custom_extra_media_handlers(): + extra_media_types = ['application/json; charset=UTF-8'] + json_handler = JSONHandler(extra_media_types=extra_media_types) + media_handlers = MediaHandlers( + default_media_type='application/json', + handlers={'application/json': json_handler} + ) + assert media_handlers.lookup_handler('application/json') is json_handler + for extra_media_type in extra_media_types: + media_handler = media_handlers.lookup_handler(extra_media_type) + assert media_handler is media_handlers.handlers[extra_media_type] + assert media_handler is json_handler + + media_handlers = MediaHandlers( + default_media_type='application/json', + handlers={ + 'application/json': json_handler, + 'application/json; charset=UTF-8': JSONHandler() + } + ) + + assert media_handlers.lookup_handler('application/json') is json_handler + for extra_media_type in extra_media_types: + media_handler = media_handlers.lookup_handler(extra_media_type) + assert media_handler is media_handlers.handlers[extra_media_type] + assert media_handler is not json_handler From 3cf7d7f4fd522a0432a433df7ae108075063f35f Mon Sep 17 00:00:00 2001 From: eitanp82 Date: Sun, 3 Feb 2019 14:55:43 +0200 Subject: [PATCH 2/6] Fix compatibility with older falcon versions. --- src/graceful/media/base.py | 6 +++++- src/graceful/media/handlers.py | 10 ++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/graceful/media/base.py b/src/graceful/media/base.py index 9e0a740..a97f9ff 100644 --- a/src/graceful/media/base.py +++ b/src/graceful/media/base.py @@ -83,8 +83,12 @@ def handle_request(self, req, *, content_type=None, **kwargs): """ content_type = content_type or req.content_type if content_type in self.allowed_media_types: - req._media = self.deserialize( + media = self.deserialize( req.stream, content_type, req.content_length, **kwargs) + try: + req.media = media + except AttributeError: + req._media = media else: allowed = ', '.join("'{}'".format(media_type) for media_type in self.allowed_media_types) diff --git a/src/graceful/media/handlers.py b/src/graceful/media/handlers.py index 21ecabb..6b058e0 100644 --- a/src/graceful/media/handlers.py +++ b/src/graceful/media/handlers.py @@ -86,7 +86,10 @@ def handle_response(self, resp, *, media, **kwargs): """ content_type = resp.content_type or self.media_type - default_media_type = resp.options.default_media_type + try: + default_media_type = resp.options.default_media_type + except AttributeError: + default_media_type = self.media_type handler = self.lookup_handler(content_type, default_media_type) super().handle_response(resp, media=media, handler=handler) resp.content_type = handler.media_type @@ -103,7 +106,10 @@ def handle_request(self, req, *, content_type=None, **kwargs): """ content_type = content_type or req.content_type - default_media_type = req.options.default_media_type + try: + default_media_type = req.options.default_media_type + except AttributeError: + default_media_type = self.media_type handler = self.lookup_handler(content_type, default_media_type) super().handle_request(req, content_type=content_type, handler=handler) From d40e1667d6d1e45e4e78b4a2f20ec190ebabec74 Mon Sep 17 00:00:00 2001 From: eitanp82 Date: Sun, 3 Feb 2019 14:58:55 +0200 Subject: [PATCH 3/6] Fix PEP8 issue (E501). --- src/graceful/media/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/graceful/media/base.py b/src/graceful/media/base.py index a97f9ff..19f4b11 100644 --- a/src/graceful/media/base.py +++ b/src/graceful/media/base.py @@ -11,7 +11,8 @@ class BaseMediaHandler(metaclass=ABCMeta): deserialize the body stream of request objects Attributes: - allowed_media_types (set): All media types supported for deserialization + allowed_media_types (set): All media types supported for + deserialization """ From 9a6fe3cdf391353d98fff84f1aeba58f8217f9d2 Mon Sep 17 00:00:00 2001 From: eitanp82 Date: Sun, 3 Feb 2019 20:50:02 +0200 Subject: [PATCH 4/6] 1. Fixed support on earlier falcon versions. 2. Fixed content types guide. --- docs/guide/content-types.rst | 6 +++--- src/graceful/media/base.py | 13 ++++++++----- src/graceful/media/handlers.py | 15 ++++++++++++--- src/graceful/resources/base.py | 5 ++--- tests/test_media_handlers.py | 7 +++---- 5 files changed, 28 insertions(+), 18 deletions(-) diff --git a/docs/guide/content-types.rst b/docs/guide/content-types.rst index e920902..2679b2d 100644 --- a/docs/guide/content-types.rst +++ b/docs/guide/content-types.rst @@ -16,7 +16,7 @@ Here are some resources that be used in the following examples: from graceful.serializers import BaseSerializer from graceful.fields import IntField, RawField from graceful.parameters import StringParam - from graceful.resources.generic import RetrieveAPI, PaginatedListAPI + from graceful.resources.generic import RetrieveAPI, ListCreateAPI CATS_STORAGE = [ @@ -44,10 +44,10 @@ Here are some resources that be used in the following examples: raise falcon.HTTPNotFound def retrieve(self, params, meta, context, *, cat_id, **kwargs): - return self.get_cat(cat_id) + return self.get_cat(int(cat_id)) - class BaseCatListResource(PaginatedListAPI, with_context=True): + class BaseCatListResource(ListCreateAPI, with_context=True): """List of all cats in our API.""" serializer = CatSerializer() diff --git a/src/graceful/media/base.py b/src/graceful/media/base.py index 19f4b11..7f89eda 100644 --- a/src/graceful/media/base.py +++ b/src/graceful/media/base.py @@ -61,6 +61,9 @@ def handle_response(self, resp, *, media, **kwargs): resp (falcon.Response): The response object to process media (object): A Python data structure to serialize + Returns: + A serialized (``str`` or ``bytes``) representation of ``media``. + """ # sets the Content-Type header resp.content_type = self.media_type @@ -70,6 +73,7 @@ def handle_response(self, resp, *, media, **kwargs): resp.data = data else: resp.body = data + return data def handle_request(self, req, *, content_type=None, **kwargs): """Process a single :class:`falcon.Request` object. @@ -78,18 +82,17 @@ def handle_request(self, req, *, content_type=None, **kwargs): req (falcon.Request): The request object to process content_type (str): Type of request content + Returns: + object: A deserialized object from a :class:`falcon.Request` body. + Raises: falcon.HTTPUnsupportedMediaType: If `content_type` is not supported """ content_type = content_type or req.content_type if content_type in self.allowed_media_types: - media = self.deserialize( + return self.deserialize( req.stream, content_type, req.content_length, **kwargs) - try: - req.media = media - except AttributeError: - req._media = media else: allowed = ', '.join("'{}'".format(media_type) for media_type in self.allowed_media_types) diff --git a/src/graceful/media/handlers.py b/src/graceful/media/handlers.py index 6b058e0..90945ed 100644 --- a/src/graceful/media/handlers.py +++ b/src/graceful/media/handlers.py @@ -84,6 +84,9 @@ def handle_response(self, resp, *, media, **kwargs): resp (falcon.Response): The response object to process media (object): A Python data structure to serialize + Returns: + A serialized (``str`` or ``bytes``) representation of ``media``. + """ content_type = resp.content_type or self.media_type try: @@ -91,8 +94,10 @@ def handle_response(self, resp, *, media, **kwargs): except AttributeError: default_media_type = self.media_type handler = self.lookup_handler(content_type, default_media_type) - super().handle_response(resp, media=media, handler=handler) - resp.content_type = handler.media_type + try: + return super().handle_response(resp, media=media, handler=handler) + finally: + resp.content_type = handler.media_type def handle_request(self, req, *, content_type=None, **kwargs): """Process a single :class:`falcon.Request` object. @@ -101,6 +106,9 @@ def handle_request(self, req, *, content_type=None, **kwargs): req (falcon.Request): The request object to process content_type (str): Type of request content + Returns: + object: A deserialized object from a :class:`falcon.Request` body. + Raises: falcon.HTTPUnsupportedMediaType: If `content_type` is not supported @@ -111,7 +119,8 @@ def handle_request(self, req, *, content_type=None, **kwargs): except AttributeError: default_media_type = self.media_type handler = self.lookup_handler(content_type, default_media_type) - super().handle_request(req, content_type=content_type, handler=handler) + return super().handle_request( + req, content_type=content_type, handler=handler) def lookup_handler(self, media_type, default_media_type=None): """Lookup media handler by media type. diff --git a/src/graceful/resources/base.py b/src/graceful/resources/base.py index a9dc3c6..98aa597 100644 --- a/src/graceful/resources/base.py +++ b/src/graceful/resources/base.py @@ -388,9 +388,8 @@ def require_representation(self, req): req.content_type ) ) - - self.media_handler.handle_request(req, content_type=content_type) - return req.media + return self.media_handler.handle_request( + req, content_type=content_type) def require_validated(self, req, partial=False, bulk=False): """Require fully validated internal object dictionary. diff --git a/tests/test_media_handlers.py b/tests/test_media_handlers.py index 5298b36..29c1ad4 100644 --- a/tests/test_media_handlers.py +++ b/tests/test_media_handlers.py @@ -167,8 +167,7 @@ def test_subclass_json_loads(subclass_json_handler): def test_handle_request(media_handler, req, media, media_json): - media_handler.handle_request(req, content_type=media_json) - assert req.media == media + assert media_handler.handle_request(req, content_type=media_json) == media def test_handle_request_unsupported_media_type(media_handler, req): @@ -177,8 +176,8 @@ def test_handle_request_unsupported_media_type(media_handler, req): def test_handle_response(media_handler, resp, media): - media_handler.handle_response(resp, media=media) - assert resp.data or resp.body + data = media_handler.handle_response(resp, media=media) + assert (resp.data or resp.body) == data assert resp.data or isinstance(resp.body, str) assert resp.body or isinstance(resp.data, bytes) From 3e2dd455a1db5420a7f2549ed5627af8fed7e575 Mon Sep 17 00:00:00 2001 From: eitanp82 Date: Sun, 3 Feb 2019 21:05:13 +0200 Subject: [PATCH 5/6] Fix PEP257 issue (D102). --- src/graceful/media/handlers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/graceful/media/handlers.py b/src/graceful/media/handlers.py index 90945ed..8acc5f6 100644 --- a/src/graceful/media/handlers.py +++ b/src/graceful/media/handlers.py @@ -23,6 +23,7 @@ class MediaHandlers(BaseMediaHandler): """ def __init__(self, default_media_type='application/json', handlers=None): + """The __init__ method documented in the class level.""" self.default_media_type = default_media_type self.handlers = handlers or { 'application/json': JSONHandler(), From 597cc5dccc18b584c47614425f3807607c189fa7 Mon Sep 17 00:00:00 2001 From: eitanp82 Date: Sun, 3 Feb 2019 21:11:36 +0200 Subject: [PATCH 6/6] Fix test issue on Python 3.5. --- tests/test_media_handlers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_media_handlers.py b/tests/test_media_handlers.py index 29c1ad4..3669a7b 100644 --- a/tests/test_media_handlers.py +++ b/tests/test_media_handlers.py @@ -1,6 +1,7 @@ import copy import io import json +import sys import pytest @@ -257,6 +258,8 @@ def test_media_handlers_lookup_unknown_media_type(media_handlers): media_handlers.lookup_handler(None, 'nope/json') +@pytest.mark.skipif(sys.version_info[:2] == (3, 5), + reason='mocker issue on python3.5') @pytest.mark.parametrize('default_media_type', [ 'application/json', 'application/yaml'