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..2679b2d 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, ListCreateAPI
+
+
+ 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(int(cat_id))
+
+
+ class BaseCatListResource(ListCreateAPI, 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..7f89eda
--- /dev/null
+++ b/src/graceful/media/base.py
@@ -0,0 +1,107 @@
+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
+
+ Returns:
+ A serialized (``str`` or ``bytes``) representation of ``media``.
+
+ """
+ # 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
+ return 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
+
+ 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:
+ return 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..8acc5f6
--- /dev/null
+++ b/src/graceful/media/handlers.py
@@ -0,0 +1,162 @@
+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):
+ """The __init__ method documented in the class level."""
+ 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
+
+ Returns:
+ A serialized (``str`` or ``bytes``) representation of ``media``.
+
+ """
+ content_type = resp.content_type or self.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)
+ 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.
+
+ Args:
+ 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
+ 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)
+ 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.
+
+ 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..98aa597 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
@@ -382,14 +388,8 @@ def require_representation(self, req):
req.content_type
)
)
-
- 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)
- )
+ 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
new file mode 100644
index 0000000..3669a7b
--- /dev/null
+++ b/tests/test_media_handlers.py
@@ -0,0 +1,346 @@
+import copy
+import io
+import json
+import sys
+
+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):
+ assert media_handler.handle_request(req, content_type=media_json) == 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):
+ 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)
+
+
+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.skipif(sys.version_info[:2] == (3, 5),
+ reason='mocker issue on python3.5')
+@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