Merge pull request #2358 from davidism/json-mixin
JSON support for test client and Response
This commit is contained in:
commit
63129e8426
7 changed files with 187 additions and 106 deletions
1
AUTHORS
1
AUTHORS
|
|
@ -9,6 +9,7 @@ Development Lead
|
|||
Patches and Suggestions
|
||||
```````````````````````
|
||||
|
||||
- Adam Byrtek
|
||||
- Adam Zapletal
|
||||
- Ali Afshar
|
||||
- Chris Edgemon
|
||||
|
|
|
|||
7
CHANGES
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
|
||||
--------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 . import json
|
||||
from .globals import _request_ctx_stack
|
||||
|
||||
_missing = object()
|
||||
from flask import json
|
||||
from flask.globals import current_app
|
||||
|
||||
|
||||
def _get_data(req, cache):
|
||||
getter = getattr(req, 'get_data', None)
|
||||
if getter is not None:
|
||||
return getter(cache=cache)
|
||||
return req.data
|
||||
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)
|
||||
|
||||
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')
|
||||
|
||||
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))
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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…
Add table
Add a link
Reference in a new issue