From c4139e0e5de5ffa8f103956bdcc1ca7cf9e500fd Mon Sep 17 00:00:00 2001 From: Adam Byrtek Date: Sun, 29 Mar 2015 22:05:32 +0100 Subject: [PATCH 01/17] JSON support for the Flask test client --- flask/testing.py | 15 ++++++++++++++- tests/test_testing.py | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/flask/testing.py b/flask/testing.py index 8eacf58b..dd879c37 100644 --- a/flask/testing.py +++ b/flask/testing.py @@ -13,6 +13,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 @@ -20,7 +21,7 @@ except ImportError: from urlparse import urlsplit as url_parse -def make_test_environ_builder(app, path='/', base_url=None, *args, **kwargs): +def make_test_environ_builder(app, path='/', base_url=None, json=None, *args, **kwargs): """Creates a new test builder with some application defaults thrown in.""" http_host = app.config.get('SERVER_NAME') app_root = app.config.get('APPLICATION_ROOT') @@ -33,6 +34,18 @@ def make_test_environ_builder(app, path='/', base_url=None, *args, **kwargs): path = url.path if url.query: path += '?' + url.query + + if json: + if 'data' in kwargs: + raise RuntimeError('Client cannot provide both `json` and `data`') + kwargs['data'] = json_dumps(json) + + # Only set Content-Type when not explicitly provided + if 'Content-Type' not in kwargs.get('headers', {}): + new_headers = kwargs.get('headers', {}).copy() + new_headers['Content-Type'] = 'application/json' + kwargs['headers'] = new_headers + return EnvironBuilder(path, base_url, *args, **kwargs) diff --git a/tests/test_testing.py b/tests/test_testing.py index 7bb99e79..b3f4defe 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -203,6 +203,20 @@ def test_full_url_request(): assert 'gin' in flask.request.form assert 'vodka' in flask.request.args +def test_json_request(): + app = flask.Flask(__name__) + app.testing = True + + @app.route('/api', methods=['POST']) + def api(): + return '' + + with app.test_client() as c: + json_data = {'drink': {'gin': 1, 'tonic': True}, 'price': 10} + rv = c.post('/api', json=json_data) + assert rv.status_code == 200 + assert flask.request.get_json() == json_data + def test_subdomain(): app = flask.Flask(__name__) app.config['SERVER_NAME'] = 'example.com' From 6c5ef2bc5c7c304c8d4a2ddbb8666167ae73d4c4 Mon Sep 17 00:00:00 2001 From: Adam Byrtek Date: Sun, 29 Mar 2015 23:05:40 +0100 Subject: [PATCH 02/17] Use `content_type` kwarg instead of manipulating headers --- flask/testing.py | 8 +++----- tests/test_testing.py | 1 + 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/flask/testing.py b/flask/testing.py index dd879c37..3b2f26ef 100644 --- a/flask/testing.py +++ b/flask/testing.py @@ -35,16 +35,14 @@ def make_test_environ_builder(app, path='/', base_url=None, json=None, *args, ** if url.query: path += '?' + url.query - if json: + if json is not None: if 'data' in kwargs: raise RuntimeError('Client cannot provide both `json` and `data`') kwargs['data'] = json_dumps(json) # Only set Content-Type when not explicitly provided - if 'Content-Type' not in kwargs.get('headers', {}): - new_headers = kwargs.get('headers', {}).copy() - new_headers['Content-Type'] = 'application/json' - kwargs['headers'] = new_headers + if 'content_type' not in kwargs: + kwargs['content_type'] = 'application/json' return EnvironBuilder(path, base_url, *args, **kwargs) diff --git a/tests/test_testing.py b/tests/test_testing.py index b3f4defe..727f08f7 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -215,6 +215,7 @@ def test_json_request(): json_data = {'drink': {'gin': 1, 'tonic': True}, 'price': 10} rv = c.post('/api', json=json_data) assert rv.status_code == 200 + assert flask.request.is_json assert flask.request.get_json() == json_data def test_subdomain(): From b099999c6cb5c3563dc4fbe45f3101116aab0d3f Mon Sep 17 00:00:00 2001 From: Adam Byrtek Date: Wed, 1 Apr 2015 00:11:52 +0100 Subject: [PATCH 03/17] Use proper exception type and update changelog --- AUTHORS | 1 + CHANGES | 3 +++ flask/testing.py | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index cc157dc4..b4d746f5 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 9e13bd71..ea268229 100644 --- a/CHANGES +++ b/CHANGES @@ -11,6 +11,9 @@ Version 0.12 See pull request ``#1849``. - 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. Version 0.11.2 -------------- diff --git a/flask/testing.py b/flask/testing.py index 3b2f26ef..6527c8b4 100644 --- a/flask/testing.py +++ b/flask/testing.py @@ -37,7 +37,7 @@ def make_test_environ_builder(app, path='/', base_url=None, json=None, *args, ** if json is not None: if 'data' in kwargs: - raise RuntimeError('Client cannot provide both `json` and `data`') + raise ValueError('Client cannot provide both `json` and `data`') kwargs['data'] = json_dumps(json) # Only set Content-Type when not explicitly provided From ca547f0ec375a8a3e8c623cfa6b747d68e75a239 Mon Sep 17 00:00:00 2001 From: Adam Byrtek Date: Wed, 1 Apr 2015 23:08:06 +0100 Subject: [PATCH 04/17] JSON response tests and first draft of code that passes --- flask/wrappers.py | 81 +++++++++++++++++++++++++++++++++++++++---- tests/test_testing.py | 19 ++++++---- 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/flask/wrappers.py b/flask/wrappers.py index d1d7ba7d..fcf732e3 100644 --- a/flask/wrappers.py +++ b/flask/wrappers.py @@ -25,6 +25,14 @@ def _get_data(req, cache): return req.data +def _is_mimetype_json(mimetype): + if mimetype == 'application/json': + return True + if mimetype.startswith('application/') and mimetype.endswith('+json'): + return True + return False + + class Request(RequestBase): """The request object used by default in Flask. Remembers the matched endpoint and view arguments. @@ -115,12 +123,7 @@ class Request(RequestBase): .. 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 _is_mimetype_json(self.mimetype) def get_json(self, force=False, silent=False, cache=True): """Parses the incoming JSON request data and returns it. By default @@ -202,3 +205,69 @@ class Response(ResponseBase): set :attr:`~flask.Flask.response_class` to your subclass. """ default_mimetype = 'text/html' + + @property + def json(self): + """If the mimetype is :mimetype:`application/json` this will contain the + parsed JSON data. Otherwise this will be ``None``. + + The :meth:`get_json` method should be used instead. + + .. versionadded:: 1.0 + """ + 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 response is JSON or not. By default a response + is considered to include JSON data if the mimetype is + :mimetype:`application/json` or :mimetype:`application/*+json`. + + .. versionadded:: 1.0 + """ + return _is_mimetype_json(self.mimetype) + + def get_json(self, force=False, silent=False, cache=True): + """Parses the incoming JSON request data and returns it. If + parsing fails the :meth:`on_json_loading_failed` method on the + request object will be invoked. By default this function will + only load the json data if the mimetype is :mimetype:`application/json` + but this can be overridden by the `force` parameter. + + :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. + + .. versionadded:: 1.0 + """ + rv = getattr(self, '_cached_json', _missing) + if 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 diff --git a/tests/test_testing.py b/tests/test_testing.py index 727f08f7..873fe264 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -13,6 +13,7 @@ import pytest import flask from flask._compat import text_type +from flask.json import jsonify def test_environ_defaults_from_config(): @@ -203,21 +204,27 @@ def test_full_url_request(): assert 'gin' in flask.request.form assert 'vodka' in flask.request.args -def test_json_request(): +def test_json_request_and_response(): app = flask.Flask(__name__) app.testing = True - @app.route('/api', methods=['POST']) - def api(): - return '' + @app.route('/echo', methods=['POST']) + def echo(): + return jsonify(flask.request.json) with app.test_client() as c: json_data = {'drink': {'gin': 1, 'tonic': True}, 'price': 10} - rv = c.post('/api', json=json_data) - assert rv.status_code == 200 + rv = c.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 = flask.Flask(__name__) app.config['SERVER_NAME'] = 'example.com' From c9ef500c5c2bbc7cfcb51e8b599e00248d290fd4 Mon Sep 17 00:00:00 2001 From: Adam Byrtek Date: Thu, 2 Apr 2015 00:50:08 +0100 Subject: [PATCH 05/17] Mixin for JSON decoding code shared between request/response --- flask/wrappers.py | 218 +++++++++++++++++----------------------------- 1 file changed, 81 insertions(+), 137 deletions(-) diff --git a/flask/wrappers.py b/flask/wrappers.py index fcf732e3..bd6aa0f3 100644 --- a/flask/wrappers.py +++ b/flask/wrappers.py @@ -25,15 +25,88 @@ def _get_data(req, cache): return req.data -def _is_mimetype_json(mimetype): - if mimetype == 'application/json': - return True - if mimetype.startswith('application/') and mimetype.endswith('+json'): - return True - return False +class JSONMixin(object): + """Mixin for both request and response classes to provide JSON parsing + capabilities. + + .. versionadded:: 0.12 + """ + + @property + def is_json(self): + """Indicates if this request/response is JSON or not. By default it + is considered to include JSON data if the mimetype is + :mimetype:`application/json` or :mimetype:`application/*+json`. + """ + mt = self.mimetype + if mt == 'application/json': + return True + if mt.startswith('application/') and mt.endswith('+json'): + return True + return False + + @property + def json(self): + """If the mimetype is :mimetype:`application/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() + + 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 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 request. + """ + rv = getattr(self, '_cached_json', _missing) + if 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. For responses, we assume + # that if the response charset was set explicitly then the data had + # been encoded correctly as well. + charset = self.mimetype_params.get('charset') + try: + data = _get_data(self, cache) + if charset is not None: + rv = json.loads(data, encoding=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. + """ + raise BadRequest() -class Request(RequestBase): +class Request(RequestBase, JSONMixin): """The request object used by default in Flask. Remembers the matched endpoint and view arguments. @@ -103,69 +176,6 @@ 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 mimetype is :mimetype:`application/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 - """ - return _is_mimetype_json(self.mimetype) - - 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 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 request. - """ - rv = getattr(self, '_cached_json', _missing) - if 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 @@ -195,7 +205,7 @@ class Request(RequestBase): 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 @@ -205,69 +215,3 @@ class Response(ResponseBase): set :attr:`~flask.Flask.response_class` to your subclass. """ default_mimetype = 'text/html' - - @property - def json(self): - """If the mimetype is :mimetype:`application/json` this will contain the - parsed JSON data. Otherwise this will be ``None``. - - The :meth:`get_json` method should be used instead. - - .. versionadded:: 1.0 - """ - 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 response is JSON or not. By default a response - is considered to include JSON data if the mimetype is - :mimetype:`application/json` or :mimetype:`application/*+json`. - - .. versionadded:: 1.0 - """ - return _is_mimetype_json(self.mimetype) - - def get_json(self, force=False, silent=False, cache=True): - """Parses the incoming JSON request data and returns it. If - parsing fails the :meth:`on_json_loading_failed` method on the - request object will be invoked. By default this function will - only load the json data if the mimetype is :mimetype:`application/json` - but this can be overridden by the `force` parameter. - - :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. - - .. versionadded:: 1.0 - """ - rv = getattr(self, '_cached_json', _missing) - if 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 From 23de58682c730a67820e77605386a16303d57447 Mon Sep 17 00:00:00 2001 From: Adam Byrtek Date: Thu, 2 Apr 2015 01:13:48 +0100 Subject: [PATCH 06/17] Remove redundant `cache` flag --- docs/testing.rst | 8 ++++++++ flask/wrappers.py | 6 +++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/testing.rst b/docs/testing.rst index fdf57937..90544f7d 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -353,3 +353,11 @@ 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 comprehensive diff --git a/flask/wrappers.py b/flask/wrappers.py index bd6aa0f3..904d50fd 100644 --- a/flask/wrappers.py +++ b/flask/wrappers.py @@ -18,10 +18,10 @@ from .globals import _request_ctx_stack _missing = object() -def _get_data(req, cache): +def _get_data(req): getter = getattr(req, 'get_data', None) if getter is not None: - return getter(cache=cache) + return getter() return req.data @@ -84,7 +84,7 @@ class JSONMixin(object): # been encoded correctly as well. charset = self.mimetype_params.get('charset') try: - data = _get_data(self, cache) + data = _get_data(self) if charset is not None: rv = json.loads(data, encoding=charset) else: From 539569e5f2074427a99d31a6142cbee596b5905f Mon Sep 17 00:00:00 2001 From: Adam Byrtek Date: Thu, 2 Apr 2015 01:34:51 +0100 Subject: [PATCH 07/17] Update the testing documentation --- docs/testing.rst | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/testing.rst b/docs/testing.rst index 90544f7d..03a5603d 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -360,4 +360,24 @@ Testing JSON APIs .. versionadded:: 1.0 -Flask has comprehensive +Flask has a 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: + + from flask import json, jsonify + + @app.route('/api/auth') + def auth(): + email = request.json['email'] + password = request.json['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={'email': email, 'password': password}) + assert verify_token(email, resp.json['token']) + +Note that if the ``json`` argument is provided then the test client will put +the JSON-serialized object in the request body, and also set the +``Content-Type: application/json`` header. From f0f458e0c5c4610cc210ef80a89b2b0870baa1b6 Mon Sep 17 00:00:00 2001 From: Adam Byrtek Date: Thu, 2 Apr 2015 01:45:03 +0100 Subject: [PATCH 08/17] Alternative solution for lack of response caching --- flask/wrappers.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/flask/wrappers.py b/flask/wrappers.py index 904d50fd..dfba4ed9 100644 --- a/flask/wrappers.py +++ b/flask/wrappers.py @@ -18,13 +18,6 @@ from .globals import _request_ctx_stack _missing = object() -def _get_data(req): - getter = getattr(req, 'get_data', None) - if getter is not None: - return getter() - return req.data - - class JSONMixin(object): """Mixin for both request and response classes to provide JSON parsing capabilities. @@ -57,6 +50,12 @@ class JSONMixin(object): 'Use get_json() instead.'), stacklevel=2) return self.get_json() + def _get_data_for_json(req, cache): + getter = getattr(req, 'get_data', None) + if getter is not None: + return getter(cache=cache) + return req.data + 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 mimetype is not @@ -84,7 +83,7 @@ class JSONMixin(object): # been encoded correctly as well. charset = self.mimetype_params.get('charset') try: - data = _get_data(self) + data = self._get_data_for_json(cache) if charset is not None: rv = json.loads(data, encoding=charset) else: @@ -215,3 +214,10 @@ class Response(ResponseBase, JSONMixin): set :attr:`~flask.Flask.response_class` to your subclass. """ default_mimetype = 'text/html' + + def _get_data_for_json(req, cache): + # Ignore the cache flag since response doesn't support it + getter = getattr(req, 'get_data', None) + if getter is not None: + return getter() + return req.data From f0d3b71a94994d52d64d315bc8ef7371db14d76f Mon Sep 17 00:00:00 2001 From: Adam Byrtek Date: Mon, 6 Apr 2015 13:15:07 +0200 Subject: [PATCH 09/17] Updates after code review --- CHANGES | 2 ++ docs/testing.rst | 16 ++++++++-------- flask/wrappers.py | 14 +++++++------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/CHANGES b/CHANGES index ea268229..a1d0b311 100644 --- a/CHANGES +++ b/CHANGES @@ -14,6 +14,8 @@ Version 0.12 - 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. Version 0.11.2 -------------- diff --git a/docs/testing.rst b/docs/testing.rst index 03a5603d..d8c3bac1 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -360,24 +360,24 @@ Testing JSON APIs .. versionadded:: 1.0 -Flask has a great support for JSON, and is a popular choice for building REST +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: - from flask import json, jsonify + from flask import jsonify @app.route('/api/auth') def auth(): - email = request.json['email'] - password = request.json['password'] + email = request.get_json()['email'] + password = request.get_json()['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={'email': email, 'password': password}) - assert verify_token(email, resp.json['token']) + resp = c.post('/api/auth', json={'login': email, 'password': password}) + assert verify_token(email, resp.get_json()['token']) Note that if the ``json`` argument is provided then the test client will put -the JSON-serialized object in the request body, and also set the -``Content-Type: application/json`` header. +JSON-serialized data in the request body, and also set the +``Content-Type: application/json`` HTTP header. diff --git a/flask/wrappers.py b/flask/wrappers.py index dfba4ed9..c25db8b7 100644 --- a/flask/wrappers.py +++ b/flask/wrappers.py @@ -50,11 +50,11 @@ class JSONMixin(object): 'Use get_json() instead.'), stacklevel=2) return self.get_json() - def _get_data_for_json(req, cache): - getter = getattr(req, 'get_data', None) + def _get_data_for_json(self, cache): + getter = getattr(self, 'get_data', None) if getter is not None: return getter(cache=cache) - return req.data + return self.data def get_json(self, force=False, silent=False, cache=True): """Parses the incoming JSON request data and returns it. By default @@ -215,9 +215,9 @@ class Response(ResponseBase, JSONMixin): """ default_mimetype = 'text/html' - def _get_data_for_json(req, cache): - # Ignore the cache flag since response doesn't support it - getter = getattr(req, 'get_data', None) + def _get_data_for_json(self, cache): + getter = getattr(self, 'get_data', None) if getter is not None: + # Ignore the cache flag since response doesn't support it return getter() - return req.data + return self.data From 1df2788a8f29a1238cbdebd7fa59b0d6218995b5 Mon Sep 17 00:00:00 2001 From: Adam Byrtek Date: Mon, 6 Apr 2015 13:27:59 +0200 Subject: [PATCH 10/17] Use app_ctx instead of request_ctx to access the app --- flask/wrappers.py | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/flask/wrappers.py b/flask/wrappers.py index c25db8b7..ac771650 100644 --- a/flask/wrappers.py +++ b/flask/wrappers.py @@ -13,7 +13,7 @@ from werkzeug.wrappers import Request as RequestBase, Response as ResponseBase from werkzeug.exceptions import BadRequest from . import json -from .globals import _request_ctx_stack +from .globals import _app_ctx_stack _missing = object() @@ -101,7 +101,17 @@ class JSONMixin(object): """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 = _app_ctx_stack.top + if ctx is not None and ctx.app.debug: + raise BadRequest('Failed to decode JSON object: {0}'.format(e)) raise BadRequest() @@ -142,7 +152,7 @@ class Request(RequestBase, JSONMixin): @property def max_content_length(self): """Read-only view of the ``MAX_CONTENT_LENGTH`` config key.""" - ctx = _request_ctx_stack.top + ctx = _app_ctx_stack.top if ctx is not None: return ctx.app.config['MAX_CONTENT_LENGTH'] @@ -175,29 +185,12 @@ class Request(RequestBase, JSONMixin): if self.url_rule and '.' in self.url_rule.endpoint: return self.url_rule.endpoint.rsplit('.', 1)[0] - 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 + ctx = _app_ctx_stack.top if ctx is not None and ctx.app.debug and \ self.mimetype != 'multipart/form-data' and not self.files: from .debughelpers import attach_enctype_error_multidict From 5575faad92f69993e3823cd94044a2dd7796c82d Mon Sep 17 00:00:00 2001 From: Adam Byrtek Date: Mon, 6 Apr 2015 13:50:29 +0200 Subject: [PATCH 11/17] Update documentation to use the getter only once --- docs/testing.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/testing.rst b/docs/testing.rst index d8c3bac1..8b9af8bd 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -368,15 +368,17 @@ convenient: @app.route('/api/auth') def auth(): - email = request.get_json()['email'] - password = request.get_json()['password'] + 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}) - assert verify_token(email, resp.get_json()['token']) + json_data = resp.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 From 5ebdd5dd7455ca4a4c8ba1aff727f40e77a11c08 Mon Sep 17 00:00:00 2001 From: Adam Byrtek Date: Sun, 12 Apr 2015 23:34:03 +0100 Subject: [PATCH 12/17] Documentation updates --- docs/api.rst | 4 ++-- docs/testing.rst | 4 +++- flask/wrappers.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index e72c9ace..9344f7aa 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -29,7 +29,7 @@ Incoming Request Data --------------------- .. autoclass:: Request - :members: + :members: is_json, get_json .. attribute:: form @@ -141,7 +141,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 8b9af8bd..07c9aaf5 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -362,7 +362,7 @@ Testing JSON APIs 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: +convenient:: from flask import jsonify @@ -371,12 +371,14 @@ convenient: 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() assert verify_token(email, json_data['token']) diff --git a/flask/wrappers.py b/flask/wrappers.py index ac771650..a91d82c0 100644 --- a/flask/wrappers.py +++ b/flask/wrappers.py @@ -57,7 +57,7 @@ class JSONMixin(object): return self.data def get_json(self, force=False, silent=False, cache=True): - """Parses the incoming JSON request data and returns it. By default + """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 From 866118302ef910f2b216e23783c9013d282e7807 Mon Sep 17 00:00:00 2001 From: Adam Byrtek Date: Mon, 28 Sep 2015 22:20:11 +0200 Subject: [PATCH 13/17] Remove _missing sentinel and update docs --- flask/wrappers.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/flask/wrappers.py b/flask/wrappers.py index a91d82c0..ac181985 100644 --- a/flask/wrappers.py +++ b/flask/wrappers.py @@ -15,21 +15,21 @@ from werkzeug.exceptions import BadRequest from . import json from .globals import _app_ctx_stack -_missing = object() - class JSONMixin(object): - """Mixin for both request and response classes to provide JSON parsing - capabilities. + """Common mixin for both request and response objects to provide JSON + parsing capabilities. .. versionadded:: 0.12 """ @property def is_json(self): - """Indicates if this request/response is JSON or not. By default it - is considered to include JSON data if the mimetype is + """Indicates if this request/response is in JSON format or not. By + default it is considered to include JSON data if the mimetype is :mimetype:`application/json` or :mimetype:`application/*+json`. + + .. versionadded:: 1.0 """ mt = self.mimetype if mt == 'application/json': @@ -40,14 +40,14 @@ class JSONMixin(object): @property def json(self): - """If the mimetype is :mimetype:`application/json` this will contain the - parsed JSON data. Otherwise this will be ``None``. + """If this request/response is in JSON format then this property will + contain the parsed JSON data. Otherwise it 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) + warn(DeprecationWarning( + 'json is deprecated. Use get_json() instead.'), stacklevel=2) return self.get_json() def _get_data_for_json(self, cache): @@ -68,16 +68,17 @@ class JSONMixin(object): :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. + on the object. """ - rv = getattr(self, '_cached_json', _missing) - if rv is not _missing: - return rv + try: + return getattr(self, '_cached_json') + except AttributeError: + pass if not (force or self.is_json): return None - # We accept a request charset against the specification as certain + # 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. From 62b0b6652aab03fcf7c411de4d2cbd242ed05ec9 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Fri, 19 Aug 2016 21:24:07 +0200 Subject: [PATCH 14/17] testing: Make json a keyword arg --- flask/testing.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flask/testing.py b/flask/testing.py index 6527c8b4..34b66600 100644 --- a/flask/testing.py +++ b/flask/testing.py @@ -21,7 +21,7 @@ except ImportError: from urlparse import urlsplit as url_parse -def make_test_environ_builder(app, path='/', base_url=None, json=None, *args, **kwargs): +def make_test_environ_builder(app, path='/', base_url=None, *args, **kwargs): """Creates a new test builder with some application defaults thrown in.""" http_host = app.config.get('SERVER_NAME') app_root = app.config.get('APPLICATION_ROOT') @@ -35,10 +35,10 @@ def make_test_environ_builder(app, path='/', base_url=None, json=None, *args, ** if url.query: path += '?' + url.query - if json is not None: + if 'json' in kwargs: if 'data' in kwargs: raise ValueError('Client cannot provide both `json` and `data`') - kwargs['data'] = json_dumps(json) + kwargs['data'] = json_dumps(kwargs['json']) # Only set Content-Type when not explicitly provided if 'content_type' not in kwargs: From 5c4fa7e91cca82f769c11b098114aa6d3322599b Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Fri, 19 Aug 2016 21:25:27 +0200 Subject: [PATCH 15/17] Remove already defined method --- flask/wrappers.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/flask/wrappers.py b/flask/wrappers.py index ac181985..cfb02272 100644 --- a/flask/wrappers.py +++ b/flask/wrappers.py @@ -208,10 +208,3 @@ class Response(ResponseBase, JSONMixin): set :attr:`~flask.Flask.response_class` to your subclass. """ default_mimetype = 'text/html' - - def _get_data_for_json(self, cache): - getter = getattr(self, 'get_data', None) - if getter is not None: - # Ignore the cache flag since response doesn't support it - return getter() - return self.data From 136a833a8d65f2b89c277d9b909fef5f8a4628de Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Fri, 19 Aug 2016 21:29:12 +0200 Subject: [PATCH 16/17] Bugfix: EnvironBuilder doesn't take `json` --- flask/testing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask/testing.py b/flask/testing.py index 34b66600..4ecfdd10 100644 --- a/flask/testing.py +++ b/flask/testing.py @@ -38,7 +38,7 @@ def make_test_environ_builder(app, path='/', base_url=None, *args, **kwargs): if 'json' in kwargs: if 'data' in kwargs: raise ValueError('Client cannot provide both `json` and `data`') - kwargs['data'] = json_dumps(kwargs['json']) + kwargs['data'] = json_dumps(kwargs.pop('json')) # Only set Content-Type when not explicitly provided if 'content_type' not in kwargs: From e97253e4c1a0380f0b70108e8f984b0d9b87ac11 Mon Sep 17 00:00:00 2001 From: David Lord Date: Sun, 4 Jun 2017 11:44:00 -0700 Subject: [PATCH 17/17] clean up JSON code and docs --- CHANGES | 12 +++-- docs/testing.rst | 25 +++++---- flask/testing.py | 12 +++-- flask/wrappers.py | 123 ++++++++++++++++++++++-------------------- tests/test_testing.py | 2 +- 5 files changed, 91 insertions(+), 83 deletions(-) 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. + """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: 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. + :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}