Browse Source

Merge pull request #2358 from davidism/json-mixin

JSON support for test client and Response
pull/2359/head
David Lord 7 years ago committed by GitHub
parent
commit
63129e8426
  1. 1
      AUTHORS
  2. 7
      CHANGES
  3. 2
      docs/api.rst
  4. 31
      docs/testing.rst
  5. 13
      flask/testing.py
  6. 217
      flask/wrappers.py
  7. 20
      tests/test_testing.py

1
AUTHORS

@ -9,6 +9,7 @@ Development Lead
Patches and Suggestions
```````````````````````
- Adam Byrtek
- Adam Zapletal
- Ali Afshar
- Chris Edgemon

7
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
--------------

2
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

31
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``.

13
flask/testing.py

@ -14,6 +14,7 @@ import werkzeug
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)

217
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 @@ class Request(RequestBase):
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()

20
tests/test_testing.py

@ -14,6 +14,7 @@ import flask
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 test_full_url_request(app, client):
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'

Loading…
Cancel
Save