diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 88b1ba4e..d950d887 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,8 @@ Changelog Current ------- +- **Behavior**: + * ``ujson`` is not the default json serializer anymore. A new configuration option is available instead: ``RESTPLUS_JSON_SERIALIZER`` (:issue:`507`, :issue:`587`, :issue:`589`, :pr:`637`) - Add new `Wildcard` fields (:pr:`255`) - Fix ABC deprecation warnings (:pr:`580`) - Fix `@api.expect(..., validate=False)` decorators for an :class:`Api` where `validate=True` is set on the constructor (:issue:`609`, :pr:`610`) diff --git a/doc/quickstart.rst b/doc/quickstart.rst index fcb74502..c1011f13 100644 --- a/doc/quickstart.rst +++ b/doc/quickstart.rst @@ -308,6 +308,77 @@ parameter to some classes or function to force order preservation: - globally on :class:`Namespace`: ``ns = Namespace(ordered=True)`` - locally on :func:`marshal`: ``return marshal(data, fields, ordered=True)`` +Configuration +------------- + +The following configuration options exist for Flask-RESTPlus: + +============================ =============== ================================== + OPTION DEFAULT VALUE DESCRIPTION +============================ =============== ================================== +``BUNDLE_ERRORS`` ``False`` Bundle all the validation errors + instead of returning only the + first one encountered. + See the `Error Handling + `__ + section of the documentation for + details. +``RESTPLUS_VALIDATE`` ``False`` Whether to enforce payload + validation by default when using + the ``@api.expect()`` decorator. + See the `@api.expect() + `__ + documentation for details. +``RESTPLUS_MASK_HEADER`` ``X-Fields`` Choose the name of the *Header* + that will contain the masks to + apply to your answer. + See the `Fields masks `__ + documentation for details. +``RESTPLUS_MASK_SWAGGER`` ``True`` Whether to enable the mask + documentation in your swagger or + not. + See the `mask usage + `__ documentation + for details. +``RESTPLUS_JSON`` ``{}`` Dictionary of options to pass to + the json *serializer* (by default + ``json.dumps``). +``RESTPLUS_JSON_SERIALIZER`` ``None`` Here you can choose your + own/preferred json *serializer*. + You can either specify the name + of the module (example: ``ujson``) + or you can give the full name of + your *serializer* (example: + ``ujson.dumps``). + + .. note:: + If you only specify the module + name the default Flask-RESTPlus + behavior is to import its + ``dumps`` method. + + + .. note:: + Flask-RESTPlus will always + fallback to the default + ``json.dumps`` *serializer* + if it cannot manage to import + the one you configured. + In such case, a + ``UserWarning`` will be + raised. + + + .. warning:: + We only officially support + python's builtin + ``json.dumps``. + Please keep in mind some + serializers may behave + differently depending on the + input types (floats, dates, + etc.). +============================ =============== ================================== Full example ------------ diff --git a/flask_restplus/api.py b/flask_restplus/api.py index eb44b040..217b9091 100644 --- a/flask_restplus/api.py +++ b/flask_restplus/api.py @@ -32,7 +32,7 @@ from .postman import PostmanCollectionV1 from .resource import Resource from .swagger import Swagger -from .utils import default_id, camel_to_dash, unpack +from .utils import default_id, camel_to_dash, preload_serializer, unpack from .representations import output_json from ._http import HTTPStatus @@ -209,6 +209,7 @@ def _init_app(self, app): self._validate = self._validate if self._validate is not None else app.config.get('RESTPLUS_VALIDATE', False) app.config.setdefault('RESTPLUS_MASK_HEADER', 'X-Fields') app.config.setdefault('RESTPLUS_MASK_SWAGGER', True) + preload_serializer(app) def __getattr__(self, name): try: diff --git a/flask_restplus/representations.py b/flask_restplus/representations.py index a66d7eaa..ecebb7b9 100644 --- a/flask_restplus/representations.py +++ b/flask_restplus/representations.py @@ -1,18 +1,18 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals, absolute_import -try: - from ujson import dumps -except ImportError: - from json import dumps - from flask import make_response, current_app +from .utils import preload_serializer + def output_json(data, code, headers=None): '''Makes a Flask response with a JSON encoded body''' - settings = current_app.config.get('RESTPLUS_JSON', {}) + serializer = current_app.config.get('RESTPLUS_CACHED_SERIALIZER') + if serializer is None: + preload_serializer(current_app) + serializer = current_app.config.get('RESTPLUS_CACHED_SERIALIZER') # If we're in debug mode, and the indent is not set, we set it to a # reasonable value here. Note that this won't override any existing value @@ -22,7 +22,7 @@ def output_json(data, code, headers=None): # always end the json dumps with a new line # see https://github.com/mitsuhiko/flask/pull/1262 - dumped = dumps(data, **settings) + "\n" + dumped = serializer(data, **settings) + "\n" resp = make_response(dumped, code) resp.headers.extend(headers or {}) diff --git a/flask_restplus/utils.py b/flask_restplus/utils.py index 01a3fad8..6a38de64 100644 --- a/flask_restplus/utils.py +++ b/flask_restplus/utils.py @@ -2,9 +2,12 @@ from __future__ import unicode_literals import re +import importlib +import warnings from collections import OrderedDict from copy import deepcopy +from json import dumps from six import iteritems from ._http import HTTPStatus @@ -14,7 +17,51 @@ ALL_CAP_RE = re.compile('([a-z0-9])([A-Z])') -__all__ = ('merge', 'camel_to_dash', 'default_id', 'not_none', 'not_none_sorted', 'unpack') +__all__ = ('preload_serializer', 'importer', 'merge', 'camel_to_dash', 'default_id', + 'not_none', 'not_none_sorted', 'unpack') + + +def preload_serializer(app): + ''' + Preload the json serializer for the given ``app``. + + :param flask.Flask app: The flask application object + ''' + custom_serializer = app.config.get('RESTPLUS_JSON_SERIALIZER', None) + serializer = None + + # If the user wants to use a custom serializer, let it be + if custom_serializer: + try: + serializer = importer(custom_serializer, 'dumps') + except ImportError: + if '.' in custom_serializer: + mod, func = custom_serializer.rsplit('.', 1) + try: + serializer = importer(mod, func) + except ImportError: + warnings.warn("Unable to load custom serializer '{}', falling back to " + "'json.dumps'".format(custom_serializer), + UserWarning) + + # fallback, no serializer found so far, use the default one + if serializer is None: + serializer = dumps + app.config['RESTPLUS_CACHED_SERIALIZER'] = serializer + + +def importer(mod_name, obj_name, default=None): + ''' + Import the given ``obj_name`` from the given ``mod_name``. + + :param str mod_name: Module from which to import the ``obj_name`` + :param str obj_name: Object to import from ``mod_name`` + :param object default: Default object to return + + :return: Imported object + ''' + imported = importlib.import_module(mod_name) + return getattr(imported, obj_name, default) def merge(first, second): diff --git a/requirements/test.pip b/requirements/test.pip index 043c0e09..d223625e 100644 --- a/requirements/test.pip +++ b/requirements/test.pip @@ -9,3 +9,4 @@ pytest-mock==1.6.3 pytest-profiling==1.2.11 pytest-sugar==0.9.0 tzlocal +ujson diff --git a/tests/test_representations.py b/tests/test_representations.py new file mode 100644 index 00000000..de36f169 --- /dev/null +++ b/tests/test_representations.py @@ -0,0 +1,46 @@ +import pytest + +import flask_restplus.representations as rep +from flask_restplus.utils import preload_serializer + +from json import dumps, loads +from ujson import dumps as udumps, loads as uloads + +payload = { + 'id': 1, + 'name': 'toto', + 'address': 'test', +} + + +def test_representations_serialization_output_correct(app): + print(app.config) + r = rep.output_json(payload, 200) + assert loads(r.get_data(True)) == loads(dumps(payload)) + + +def test_config_custom_serializer_is_module(app, api): + # enforce a custom serializer + app.config['RESTPLUS_JSON_SERIALIZER'] = 'ujson' + # now reset serializer + preload_serializer(app) + r2 = rep.output_json(payload, 200) + assert uloads(r2.get_data(True)) == uloads(udumps(payload)) + assert app.config.get('RESTPLUS_CACHED_SERIALIZER') == udumps + + +def test_config_custom_serializer_is_function(app, api): + # test other config syntax + app.config['RESTPLUS_JSON_SERIALIZER'] = 'ujson.dumps' + preload_serializer(app) + rep.output_json(payload, 200) + assert app.config.get('RESTPLUS_CACHED_SERIALIZER') == udumps + + +def test_config_custom_serializer_fallback(app, api): + # test fallback + app.config['RESTPLUS_JSON_SERIALIZER'] = 'ujson.lol.dumps' + with pytest.warns(UserWarning): + preload_serializer(app) + rep.output_json(payload, 200) + assert app.config.get('RESTPLUS_CACHED_SERIALIZER') == dumps