diff --git a/AUTHORS b/AUTHORS index 33210243e7..cbab2a8cc6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -9,6 +9,7 @@ Development Lead Patches and Suggestions ``````````````````````` +- Adam Byrtek - Adam Zapletal - Ali Afshar - Chris Edgemon diff --git a/CHANGES b/CHANGES index 53d116978c..2c4ecdde98 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 -------------- diff --git a/docs/api.rst b/docs/api.rst index fe4f151f38..a07fe8ca31 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -85,7 +85,7 @@ Response Objects ---------------- .. autoclass:: flask.Response - :members: set_cookie, data, mimetype + :members: set_cookie, data, mimetype, is_json, get_json .. attribute:: headers diff --git a/docs/testing.rst b/docs/testing.rst index fbd3fad55d..a040b7ef03 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -375,3 +375,34 @@ independently of the session backend used:: Note that in this case you have to use the ``sess`` object instead of the :data:`flask.session` proxy. The object however itself will provide the same interface. + + +Testing JSON APIs +----------------- + +.. versionadded:: 1.0 + +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 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: + rv = c.post('/api/auth', json={ + 'username': 'flask', 'password': 'secret' + }) + json_data = rv.get_json() + assert verify_token(email, json_data['token']) + +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 5f9269f223..f73454af71 100644 --- a/flask/testing.py +++ b/flask/testing.py @@ -14,6 +14,7 @@ from contextlib import contextmanager from werkzeug.test import Client, EnvironBuilder from flask import _request_ctx_stack +from flask.json import dumps as json_dumps try: from werkzeug.urls import url_parse @@ -52,6 +53,18 @@ def make_test_environ_builder( sep = b'?' if isinstance(url.query, bytes) else '?' path += sep + url.query + if 'json' in kwargs: + assert 'data' not in kwargs, ( + "Client cannot provide both 'json' and 'data'." + ) + + # push a context so flask.json can use app's json attributes + with app.app_context(): + kwargs['data'] = json_dumps(kwargs.pop('json')) + + if 'content_type' not in kwargs: + kwargs['content_type'] = 'application/json' + return EnvironBuilder(path, base_url, *args, **kwargs) diff --git a/flask/wrappers.py b/flask/wrappers.py index 5a49132e7e..918b0a93be 100644 --- a/flask/wrappers.py +++ b/flask/wrappers.py @@ -8,24 +8,110 @@ :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 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:: 1.0 + """ + + _cached_json = Ellipsis + + @property + def is_json(self): + """Check if the mimetype indicates JSON data, either + :mimetype:`application/json` or :mimetype:`application/*+json`. + + .. versionadded:: 0.11 + """ + mt = self.mimetype + return ( + mt == 'application/json' + or (mt.startswith('application/')) and mt.endswith('+json') + ) + + @property + def json(self): + """This will contain the parsed JSON data if the mimetype indicates + JSON (:mimetype:`application/json`, see :meth:`is_json`), otherwise it + will be ``None``. + + .. deprecated:: 1.0 + Use :meth:`get_json` instead. + """ + warn(DeprecationWarning( + "'json' is deprecated. Use 'get_json()' instead." + ), stacklevel=2) + return self.get_json() + + def _get_data_for_json(self, cache): + return self.get_data(cache=cache) -from . import json -from .globals import _request_ctx_stack + def get_json(self, force=False, silent=False, cache=True): + """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. + """ + 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 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') -_missing = object() + try: + 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 :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 + 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 + """ + if current_app is not None and current_app.debug: + raise BadRequest('Failed to decode JSON object: {0}'.format(e)) -def _get_data(req, cache): - getter = getattr(req, 'get_data', None) - if getter is not None: - return getter(cache=cache) - return req.data + raise BadRequest() -class Request(RequestBase): +class Request(RequestBase, JSONMixin): """The request object used by default in Flask. Remembers the matched endpoint and view arguments. @@ -62,9 +148,8 @@ class Request(RequestBase): @property def max_content_length(self): """Read-only view of the ``MAX_CONTENT_LENGTH`` config key.""" - ctx = _request_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): @@ -95,106 +180,22 @@ def blueprint(self): if self.url_rule and '.' in self.url_rule.endpoint: return self.url_rule.endpoint.rsplit('.', 1)[0] - @property - def json(self): - """If the request has a JSON mimetype like :mimetype:`application/json` - (see :meth:`is_json`), this will contain the parsed JSON data. - Otherwise this will be ``None``. - - The :meth:`get_json` method should be used instead. - """ - from warnings import warn - warn(DeprecationWarning('json is deprecated. ' - 'Use get_json() instead.'), stacklevel=2) - return self.get_json() - - @property - def is_json(self): - """Indicates if this request is JSON or not. By default a request - is considered to include JSON data if the mimetype is - :mimetype:`application/json` or :mimetype:`application/*+json`. - - .. versionadded:: 0.11 - """ - mt = self.mimetype - if mt == 'application/json': - return True - if mt.startswith('application/') and mt.endswith('+json'): - return True - return False - - def get_json(self, force=False, silent=False, cache=True): - """Parses the incoming JSON request data and returns it. By default - this function will return ``None`` if the request does not use a JSON - mimetype like :mimetype:`application/json`. See :meth:`is_json`. 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 request. - """ - rv = getattr(self, '_cached_json', _missing) - # We return cached JSON only when the cache is enabled. - if cache and rv is not _missing: - return rv - - if not (force or self.is_json): - return None - - # We accept a request charset against the specification as - # certain clients have been using this in the past. This - # fits our general approach of being nice in what we accept - # and strict in what we send out. - request_charset = self.mimetype_params.get('charset') - try: - data = _get_data(self, cache) - if request_charset is not None: - rv = json.loads(data, encoding=request_charset) - else: - rv = json.loads(data) - 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. - - .. 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. - - .. versionadded:: 0.8 - """ - ctx = _request_ctx_stack.top - if ctx is not None and ctx.app.config.get('DEBUG', False): - raise BadRequest('Failed to decode JSON object: {0}'.format(e)) - raise BadRequest() - def _load_form_data(self): RequestBase._load_form_data(self) # In debug mode we're replacing the files multidict with an ad-hoc # subclass that raises a different error for key errors. - ctx = _request_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) -class Response(ResponseBase): +class Response(ResponseBase, JSONMixin): """The response object that is used by default in Flask. Works like the response object from Werkzeug but is set to have an HTML mimetype by default. Quite often you don't have to create this object yourself because @@ -202,5 +203,13 @@ class Response(ResponseBase): 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 0c8ddebeb1..251f5fee1d 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -14,6 +14,7 @@ import werkzeug from flask._compat import text_type +from flask.json import jsonify def test_environ_defaults_from_config(app, client): @@ -263,6 +264,25 @@ def action(): assert 'vodka' in flask.request.args +def test_json_request_and_response(app, client): + @app.route('/echo', methods=['POST']) + def echo(): + return jsonify(flask.request.get_json()) + + with client: + json_data = {'drink': {'gin': 1, 'tonic': True}, 'price': 10} + rv = client.post('/echo', json=json_data) + + # Request should be in JSON + assert flask.request.is_json + assert flask.request.get_json() == json_data + + # Response should be in JSON + assert rv.status_code == 200 + assert rv.is_json + assert rv.get_json() == json_data + + def test_subdomain(app, client): app.config['SERVER_NAME'] = 'example.com'