Skip to content

Commit

Permalink
feat(media) Initial Implementation of custom json handling (falconry#…
Browse files Browse the repository at this point in the history
  • Loading branch information
Nick Zaccardi authored and kgriffs committed Feb 6, 2019
1 parent 04decb9 commit 09e643c
Show file tree
Hide file tree
Showing 13 changed files with 187 additions and 48 deletions.
10 changes: 10 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ Breaking Changes
``False``.
- ``RequestOptions.auto_parse_qs_csv`` now defaults to ``False`` instead of
``True``.
- ``JSONHandler`` and ``HTTPError`` no longer use
`ujson` in lieu of the standard `json` library (when `ujson` is available in
the environment). Instead, ``JSONHandler`` can now be configured
to use arbitrary ``dumps()`` and ``loads()`` functions. If you
also need to customize ``HTTPError`` serialization, you can do so via
``API.set_error_serializer()``.

Changes to Supported Platforms
------------------------------
Expand All @@ -25,6 +31,10 @@ New & Improved

- Added a new ``headers`` property to the ``Response`` class.
- Removed ``six`` as a dependency.
- ``JSONHandler`` can now be configured to use arbitrary
``dumps()`` and ``loads()`` functions. This enables support not only for
using any of a number of third-party JSON libraries, but also for
customizing the keyword arguments used when (de)serializing objects.

Fixed
-----
Expand Down
10 changes: 10 additions & 0 deletions docs/changes/2.0.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ Breaking Changes
instead of ``False``.
- :attr:`~.RequestOptions.auto_parse_qs_csv` now defaults to ``False``
instead of ``True``.
- :class:`~.media.JSONHandler` and :class:`~.HTTPError` no longer use
`ujson` in lieu of the standard `json` library (when `ujson` is available in
the environment). Instead, :class:`~.media.JSONHandler` can now be configured
to use arbitrary ``dumps()`` and ``loads()`` functions. If you
also need to customize :class:`~.HTTPError` serialization, you can do so via
:meth:`~.API.set_error_serializer`.

Changes to Supported Platforms
------------------------------
Expand All @@ -25,6 +31,10 @@ New & Improved

- Added a new :attr:`~.Response.headers` property to the :class:`~.Response` class.
- Removed :py:mod:`six` from deps.
- :class:`~.media.JSONHandler` can now be configured to use arbitrary
``dumps()`` and ``loads()`` functions. This enables support not only for
using any of a number of third-party JSON libraries, but also for
customizing the keyword arguments used when (de)serializing objects.

Fixed
-----
70 changes: 64 additions & 6 deletions falcon/media/json.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,89 @@
from __future__ import absolute_import

from functools import partial

from falcon import errors
from falcon.media import BaseHandler
from falcon.util import compat
from falcon.util import json


class JSONHandler(BaseHandler):
"""JSON media handler.
This handler uses Python's :py:mod:`json` by default, but will
use :py:mod:`ujson` if available.
This handler uses Python's standard :py:mod:`json` library by default, but
can be easily configured to use any of a number of third-party JSON
libraries, depending on your needs. For example, you can often
realize a significant performance boost under CPython by using an
alternative library. Good options in this respect include `orjson`,
`python-rapidjson`, and `mujson`.
Note:
If you are deploying to PyPy, we recommend sticking with the standard
library's JSON implementation, since it will be faster in most cases
as compared to a third-party library.
Overriding the default JSON implementation is simply a matter of specifying
the desired ``dumps`` and ``loads`` functions::
import falcon
from falcon import media
import rapidjson
json_handler = media.JSONHandler(
dumps=rapidjson.dumps,
loads=rapidjson.loads,
)
extra_handlers = {
'application/json': json_handler,
}
api = falcon.API()
api.req_options.media_handlers.update(extra_handlers)
api.resp_options.media_handlers.update(extra_handlers)
By default, ``ensure_ascii`` is passed to the ``json.dumps`` function.
If you override the ``dumps`` function, you will need to explicitly set
``ensure_ascii`` to ``False`` in order to enable the serialization of
Unicode characters to UTF-8. This is easily done by using
``functools.partial`` to apply the desired keyword argument. In fact, you
can use this same technique to customize any option supported by the
``dumps`` and ``loads`` functions::
from functools import partial
from falcon import media
import rapidjson
json_handler = media.JSONHandler(
dumps=partial(
rapidjson.dumps,
ensure_ascii=False, sort_keys=True
),
)
Keyword Arguments:
dumps (func): Function to use when serializing JSON responses.
loads (func): Function to use when deserializing JSON requests.
"""

def __init__(self, dumps=None, loads=None):
self.dumps = dumps or partial(json.dumps, ensure_ascii=False)
self.loads = loads or json.loads

def deserialize(self, stream, content_type, content_length):
try:
return json.loads(stream.read().decode('utf-8'))
return self.loads(stream.read().decode('utf-8'))
except ValueError as err:
raise errors.HTTPBadRequest(
'Invalid JSON',
'Could not parse JSON body - {0}'.format(err)
)

def serialize(self, media, content_type):
result = json.dumps(media, ensure_ascii=False)
result = self.dumps(media)

if compat.PY3 or not isinstance(result, bytes):
if not isinstance(result, bytes):
return result.encode('utf-8')

return result
5 changes: 1 addition & 4 deletions falcon/testing/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,7 @@
"""

try:
from ujson import dumps as json_dumps
except ImportError:
from json import dumps as json_dumps
from json import dumps as json_dumps

import falcon

Expand Down
5 changes: 1 addition & 4 deletions falcon/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,7 @@
"""

try:
import ujson as json # NOQA
except ImportError:
import json # NOQA
import json # NOQA

# Hoist misc. utils
from falcon.util import structures
Expand Down
5 changes: 5 additions & 0 deletions requirements/tests
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,9 @@ testtools

# Handler Specific
msgpack
mujson
ujson

# Python 3 Only Handlers
python-rapidjson; python_version >= '3'
orjson; python_version >= '3' and python_version != '3.4' and platform_python_implementation != 'PyPy'
6 changes: 1 addition & 5 deletions tests/test_after_hooks.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import functools
import json

import pytest

try:
import ujson as json
except ImportError:
import json

import falcon
from falcon import testing

Expand Down
6 changes: 1 addition & 5 deletions tests/test_before_hooks.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import functools
import io
import json

import pytest

try:
import ujson as json
except ImportError:
import json

import falcon
import falcon.testing as testing

Expand Down
5 changes: 1 addition & 4 deletions tests/test_example.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
try:
import ujson as json
except ImportError:
import json
import json
import logging
import uuid
from wsgiref import simple_server
Expand Down
96 changes: 90 additions & 6 deletions tests/test_media_handlers.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,97 @@
from functools import partial
import io
import json
import platform
import sys

import mujson
import pytest
import ujson

from falcon import media

orjson = None
rapidjson = None
if sys.version_info >= (3, 5):
import rapidjson

if platform.python_implementation() == 'CPython':
import orjson


COMMON_SERIALIZATION_PARAM_LIST = [
# Default json.dumps, with only ascii
(None, {'test': 'value'}, b'{"test":"value"}'),
(mujson.dumps, {'test': 'value'}, b'{"test":"value"}'),
(ujson.dumps, {'test': 'value'}, b'{"test":"value"}'),
(partial(lambda media, **kwargs: json.dumps([media, kwargs]),
ensure_ascii=True),
{'test': 'value'},
b'[{"test":"value"},{"ensure_ascii":true}]'),
]

COMMON_DESERIALIZATION_PARAM_LIST = [
(None, b'[1, 2]', [1, 2]),
(partial(json.loads,
object_hook=lambda data: {k: v.upper() for k, v in data.items()}),
b'{"key": "value"}',
{'key': 'VALUE'}),

(mujson.loads, b'{"test": "value"}', {'test': 'value'}),
(ujson.loads, b'{"test": "value"}', {'test': 'value'}),
]

YEN = b'\xc2\xa5'

if orjson:
SERIALIZATION_PARAM_LIST = COMMON_SERIALIZATION_PARAM_LIST + [
# Default json.dumps, with non-ascii characters
(None, {'yen': YEN.decode()}, b'{"yen":"' + YEN + b'"}'),

# Extra Python 3 json libraries
(rapidjson.dumps, {'test': 'value'}, b'{"test":"value"}'),
(orjson.dumps, {'test': 'value'}, b'{"test":"value"}'),
]

DESERIALIZATION_PARAM_LIST = COMMON_DESERIALIZATION_PARAM_LIST + [
(rapidjson.loads, b'{"test": "value"}', {'test': 'value'}),
(orjson.loads, b'{"test": "value"}', {'test': 'value'}),
]
elif rapidjson:
SERIALIZATION_PARAM_LIST = COMMON_SERIALIZATION_PARAM_LIST + [
# Default json.dumps, with non-ascii characters
(None, {'yen': YEN.decode()}, b'{"yen":"' + YEN + b'"}'),

# Extra Python 3 json libraries
(rapidjson.dumps, {'test': 'value'}, b'{"test":"value"}'),
]

DESERIALIZATION_PARAM_LIST = COMMON_DESERIALIZATION_PARAM_LIST + [
(rapidjson.loads, b'{"test": "value"}', {'test': 'value'}),
]
else:
SERIALIZATION_PARAM_LIST = COMMON_SERIALIZATION_PARAM_LIST + [
# Default json.dumps, with non-ascii characters
(None, {'yen': YEN.decode('utf-8')}, b'{"yen":"' + YEN + b'"}'),
]
DESERIALIZATION_PARAM_LIST = COMMON_DESERIALIZATION_PARAM_LIST


@pytest.mark.parametrize('func, body, expected', SERIALIZATION_PARAM_LIST)
def test_serialization(func, body, expected):
JH = media.JSONHandler(dumps=func)

# NOTE(nZac) PyPy and CPython render the final string differently. One
# includes spaces and the other doesn't. This replace will normalize that.
assert JH.serialize(body, b'application/javacript').replace(b' ', b'') == expected # noqa

def test_base_handler_contract():
class TestHandler(media.BaseHandler):
pass

with pytest.raises(TypeError) as err:
TestHandler()
@pytest.mark.parametrize('func, body, expected', DESERIALIZATION_PARAM_LIST)
def test_deserialization(func, body, expected):
JH = media.JSONHandler(loads=func)

assert 'abstract methods deserialize, serialize' in str(err.value)
assert JH.deserialize(
io.BytesIO(body),
'application/javacript',
len(body)
) == expected
6 changes: 1 addition & 5 deletions tests/test_middleware.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
from datetime import datetime
import json

import pytest

try:
import ujson as json
except ImportError:
import json

import falcon
import falcon.testing as testing

Expand Down
6 changes: 1 addition & 5 deletions tests/test_query_params.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
from datetime import date, datetime
import json
from uuid import UUID

try:
import ujson as json
except ImportError:
import json

import pytest

import falcon
Expand Down
5 changes: 1 addition & 4 deletions tests/test_wsgiref_inputwrapper_with_size.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
try:
import ujson as json
except ImportError:
import json
import json

import falcon
from falcon import testing
Expand Down

0 comments on commit 09e643c

Please sign in to comment.