diff --git a/CHANGES b/CHANGES index 7e9f1f75..2c4ecdde 100644 --- a/CHANGES +++ b/CHANGES @@ -70,6 +70,12 @@ Major release, unreleased - Only open the session if the request has not been pushed onto the context stack yet. This allows ``stream_with_context`` generators to access the same session that the containing view uses. (`#2354`_) +- Add ``json`` keyword argument for the test client request methods. This will + dump the given object as JSON and set the appropriate content type. + (`#2358`_) +- Extract JSON handling to a mixin applied to both the request and response + classes used by Flask. This adds the ``is_json`` and ``get_json`` methods to + the response to make testing JSON response much easier. (`#2358`_) .. _#1489: https://github.com/pallets/flask/pull/1489 .. _#1621: https://github.com/pallets/flask/pull/1621 @@ -91,6 +97,7 @@ Major release, unreleased .. _#2348: https://github.com/pallets/flask/pull/2348 .. _#2352: https://github.com/pallets/flask/pull/2352 .. _#2354: https://github.com/pallets/flask/pull/2354 +.. _#2358: https://github.com/pallets/flask/pull/2358 Version 0.12.2 -------------- @@ -126,11 +133,6 @@ Released on December 21st 2016, codename Punsch. ``application/octet-stream``. See pull request ``#1988``. - Make ``flask.safe_join`` able to join multiple paths like ``os.path.join`` (pull request ``#1730``). -- Added `json` keyword argument to :meth:`flask.testing.FlaskClient.open` - (and related ``get``, ``post``, etc.), which makes it more convenient to - send JSON requests from the test client. -- Added ``is_json`` and ``get_json`` to :class:``flask.wrappers.Response`` - in order to make it easier to build assertions when testing JSON responses. - Revert a behavior change that made the dev server crash instead of returning a Internal Server Error (pull request ``#2006``). - Correctly invoke response handlers for both regular request dispatching as diff --git a/docs/testing.rst b/docs/testing.rst index 15d0d34e..a040b7ef 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -382,28 +382,27 @@ Testing JSON APIs .. versionadded:: 1.0 -Flask has great support for JSON, and is a popular choice for building REST -APIs. Testing both JSON requests and responses using the test client is very -convenient:: +Flask has great support for JSON, and is a popular choice for building JSON +APIs. Making requests with JSON data and examining JSON data in responses is +very convenient:: - from flask import jsonify + from flask import request, jsonify @app.route('/api/auth') def auth(): json_data = request.get_json() email = json_data['email'] password = json_data['password'] - return jsonify(token=generate_token(email, password)) with app.test_client() as c: - email = 'john@example.com' - password = 'secret' - resp = c.post('/api/auth', json={'login': email, 'password': password}) - - json_data = resp.get_json() + rv = c.post('/api/auth', json={ + 'username': 'flask', 'password': 'secret' + }) + json_data = rv.get_json() assert verify_token(email, json_data['token']) -Note that if the ``json`` argument is provided then the test client will put -JSON-serialized data in the request body, and also set the -``Content-Type: application/json`` HTTP header. +Passing the ``json`` argument in the test client methods sets the request data +to the JSON-serialized object and sets the content type to +``application/json``. You can get the JSON data from the request or response +with ``get_json``. diff --git a/flask/testing.py b/flask/testing.py index 54a4281e..f73454af 100644 --- a/flask/testing.py +++ b/flask/testing.py @@ -23,7 +23,7 @@ except ImportError: def make_test_environ_builder( - app, path='/', base_url=None, subdomain=None, url_scheme=None, json=None, + app, path='/', base_url=None, subdomain=None, url_scheme=None, *args, **kwargs ): """Creates a new test builder with some application defaults thrown in.""" @@ -54,12 +54,14 @@ def make_test_environ_builder( path += sep + url.query if 'json' in kwargs: - if 'data' in kwargs: - raise ValueError('Client cannot provide both `json` and `data`') + assert 'data' not in kwargs, ( + "Client cannot provide both 'json' and 'data'." + ) - kwargs['data'] = json_dumps(kwargs.pop('json')) + # push a context so flask.json can use app's json attributes + with app.app_context(): + kwargs['data'] = json_dumps(kwargs.pop('json')) - # Only set Content-Type when not explicitly provided if 'content_type' not in kwargs: kwargs['content_type'] = 'application/json' diff --git a/flask/wrappers.py b/flask/wrappers.py index cfb02272..918b0a93 100644 --- a/flask/wrappers.py +++ b/flask/wrappers.py @@ -8,111 +8,106 @@ :copyright: (c) 2015 by Armin Ronacher. :license: BSD, see LICENSE for more details. """ +from warnings import warn -from werkzeug.wrappers import Request as RequestBase, Response as ResponseBase from werkzeug.exceptions import BadRequest +from werkzeug.wrappers import Request as RequestBase, Response as ResponseBase -from . import json -from .globals import _app_ctx_stack +from flask import json +from flask.globals import current_app class JSONMixin(object): """Common mixin for both request and response objects to provide JSON parsing capabilities. - .. versionadded:: 0.12 + .. versionadded:: 1.0 """ + _cached_json = Ellipsis + @property def is_json(self): - """Indicates if this request/response is in JSON format or not. By - default it is considered to include JSON data if the mimetype is + """Check if the mimetype indicates JSON data, either :mimetype:`application/json` or :mimetype:`application/*+json`. - .. versionadded:: 1.0 + .. versionadded:: 0.11 """ mt = self.mimetype - if mt == 'application/json': - return True - if mt.startswith('application/') and mt.endswith('+json'): - return True - return False + return ( + mt == 'application/json' + or (mt.startswith('application/')) and mt.endswith('+json') + ) @property def json(self): - """If this request/response is in JSON format then this property will - contain the parsed JSON data. Otherwise it will be ``None``. + """This will contain the parsed JSON data if the mimetype indicates + JSON (:mimetype:`application/json`, see :meth:`is_json`), otherwise it + will be ``None``. - The :meth:`get_json` method should be used instead. + .. deprecated:: 1.0 + Use :meth:`get_json` instead. """ - from warnings import warn warn(DeprecationWarning( - 'json is deprecated. Use get_json() instead.'), stacklevel=2) + "'json' is deprecated. Use 'get_json()' instead." + ), stacklevel=2) return self.get_json() def _get_data_for_json(self, cache): - getter = getattr(self, 'get_data', None) - if getter is not None: - return getter(cache=cache) - return self.data + return self.get_data(cache=cache) def get_json(self, force=False, silent=False, cache=True): - """Parses the JSON request/response data and returns it. By default - this function will return ``None`` if the mimetype is not - :mimetype:`application/json` but this can be overridden by the - ``force`` parameter. If parsing fails the - :meth:`on_json_loading_failed` method on the request object will be - invoked. - - :param force: if set to ``True`` the mimetype is ignored. - :param silent: if set to ``True`` this method will fail silently - and return ``None``. - :param cache: if set to ``True`` the parsed JSON data is remembered - on the object. + """Parse and return the data as JSON. If the mimetype does not indicate + JSON (:mimetype:`application/json`, see :meth:`is_json`), this returns + ``None`` unless ``force`` is true. If parsing fails, + :meth:`on_json_loading_failed` is called and its return value is used + as the return value. + + :param force: Ignore the mimetype and always try to parse JSON. + :param silent: Silence parsing errors and return ``None`` instead. + :param cache: Store the parsed JSON to return for subsequent calls. """ - try: - return getattr(self, '_cached_json') - except AttributeError: - pass + if cache and self._cached_json is not Ellipsis: + return self._cached_json if not (force or self.is_json): return None - # We accept MIME charset header against the specification as certain - # clients have been using this in the past. For responses, we assume - # that if the response charset was set explicitly then the data had - # been encoded correctly as well. + # We accept MIME charset against the specification as certain clients + # have used this in the past. For responses, we assume that if the + # charset is set then the data has been encoded correctly as well. charset = self.mimetype_params.get('charset') + try: - data = self._get_data_for_json(cache) - if charset is not None: - rv = json.loads(data, encoding=charset) - else: - rv = json.loads(data) + data = self._get_data_for_json(cache=cache) + rv = json.loads(data, encoding=charset) except ValueError as e: if silent: rv = None else: rv = self.on_json_loading_failed(e) + if cache: self._cached_json = rv + return rv def on_json_loading_failed(self, e): - """Called if decoding of the JSON data failed. The return value of - this method is used by :meth:`get_json` when an error occurred. The - default implementation just raises a :class:`BadRequest` exception. + """Called if :meth:`get_json` parsing fails and isn't silenced. If + this method returns a value, it is used as the return value for + :meth:`get_json`. The default implementation raises a + :class:`BadRequest` exception. .. versionchanged:: 0.10 - Removed buggy previous behavior of generating a random JSON - response. If you want that behavior back you can trivially - add it by subclassing. + Raise a :exc:`BadRequest` error instead of returning an error + message as JSON. If you want that behavior you can add it by + subclassing. .. versionadded:: 0.8 """ - ctx = _app_ctx_stack.top - if ctx is not None and ctx.app.debug: + if current_app is not None and current_app.debug: raise BadRequest('Failed to decode JSON object: {0}'.format(e)) + raise BadRequest() @@ -153,9 +148,8 @@ class Request(RequestBase, JSONMixin): @property def max_content_length(self): """Read-only view of the ``MAX_CONTENT_LENGTH`` config key.""" - ctx = _app_ctx_stack.top - if ctx is not None: - return ctx.app.config['MAX_CONTENT_LENGTH'] + if current_app: + return current_app.config['MAX_CONTENT_LENGTH'] @property def endpoint(self): @@ -191,9 +185,12 @@ class Request(RequestBase, JSONMixin): # In debug mode we're replacing the files multidict with an ad-hoc # subclass that raises a different error for key errors. - ctx = _app_ctx_stack.top - if ctx is not None and ctx.app.debug and \ - self.mimetype != 'multipart/form-data' and not self.files: + if ( + current_app + and current_app.debug + and self.mimetype != 'multipart/form-data' + and not self.files + ): from .debughelpers import attach_enctype_error_multidict attach_enctype_error_multidict(self) @@ -206,5 +203,13 @@ class Response(ResponseBase, JSONMixin): If you want to replace the response object used you can subclass this and set :attr:`~flask.Flask.response_class` to your subclass. + + .. versionchanged:: 1.0 + JSON support is added to the response, like the request. This is useful + when testing to get the test client response data as JSON. """ + default_mimetype = 'text/html' + + def _get_data_for_json(self, cache): + return self.get_data() diff --git a/tests/test_testing.py b/tests/test_testing.py index b742f2b8..251f5fee 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -267,7 +267,7 @@ def test_full_url_request(app, client): def test_json_request_and_response(app, client): @app.route('/echo', methods=['POST']) def echo(): - return jsonify(flask.request.json) + return jsonify(flask.request.get_json()) with client: json_data = {'drink': {'gin': 1, 'tonic': True}, 'price': 10}