diff --git a/CHANGES.rst b/CHANGES.rst index 15c360a7a..49afe0afb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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 ------------------------------ @@ -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 ----- diff --git a/docs/changes/2.0.0.rst b/docs/changes/2.0.0.rst index d3c9efe2b..ac98286f1 100644 --- a/docs/changes/2.0.0.rst +++ b/docs/changes/2.0.0.rst @@ -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 ------------------------------ @@ -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 ----- diff --git a/falcon/media/json.py b/falcon/media/json.py index 6b993a1fc..25063069e 100644 --- a/falcon/media/json.py +++ b/falcon/media/json.py @@ -1,21 +1,79 @@ 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', @@ -23,9 +81,9 @@ def deserialize(self, stream, content_type, content_length): ) 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 diff --git a/falcon/testing/resource.py b/falcon/testing/resource.py index 6acbd7e73..2fec41327 100644 --- a/falcon/testing/resource.py +++ b/falcon/testing/resource.py @@ -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 diff --git a/falcon/util/__init__.py b/falcon/util/__init__.py index 9ee080d8e..57c5e63ff 100644 --- a/falcon/util/__init__.py +++ b/falcon/util/__init__.py @@ -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 diff --git a/requirements/tests b/requirements/tests index f13149b2c..b5f81fa18 100644 --- a/requirements/tests +++ b/requirements/tests @@ -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' diff --git a/tests/test_after_hooks.py b/tests/test_after_hooks.py index c32f4d59a..b89dbf153 100644 --- a/tests/test_after_hooks.py +++ b/tests/test_after_hooks.py @@ -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 diff --git a/tests/test_before_hooks.py b/tests/test_before_hooks.py index 74e690654..8aa53d5bd 100644 --- a/tests/test_before_hooks.py +++ b/tests/test_before_hooks.py @@ -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 diff --git a/tests/test_example.py b/tests/test_example.py index 21b914a35..fa2e888fa 100644 --- a/tests/test_example.py +++ b/tests/test_example.py @@ -1,7 +1,4 @@ -try: - import ujson as json -except ImportError: - import json +import json import logging import uuid from wsgiref import simple_server diff --git a/tests/test_media_handlers.py b/tests/test_media_handlers.py index 1a8341f17..4a6599272 100644 --- a/tests/test_media_handlers.py +++ b/tests/test_media_handlers.py @@ -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 diff --git a/tests/test_middleware.py b/tests/test_middleware.py index e41380c01..b6142e0f2 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -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 diff --git a/tests/test_query_params.py b/tests/test_query_params.py index ed6ce4be7..2474b4264 100644 --- a/tests/test_query_params.py +++ b/tests/test_query_params.py @@ -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 diff --git a/tests/test_wsgiref_inputwrapper_with_size.py b/tests/test_wsgiref_inputwrapper_with_size.py index cb5d83455..983469b9b 100644 --- a/tests/test_wsgiref_inputwrapper_with_size.py +++ b/tests/test_wsgiref_inputwrapper_with_size.py @@ -1,7 +1,4 @@ -try: - import ujson as json -except ImportError: - import json +import json import falcon from falcon import testing