Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JSON support for test client and Response #2358

Merged
merged 18 commits into from
Jun 4, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Development Lead
Patches and Suggestions
```````````````````````

- Adam Byrtek
- Adam Zapletal
- Ali Afshar
- Chris Edgemon
Expand Down
7 changes: 7 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
--------------
Expand Down
2 changes: 1 addition & 1 deletion docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions docs/testing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 13 additions & 0 deletions flask/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down
217 changes: 113 additions & 104 deletions flask/wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -95,112 +180,36 @@ 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
:meth:`~flask.Flask.make_response` will take care of that for you.

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 changes: 20 additions & 0 deletions tests/test_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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'

Expand Down