diff --git a/docs/_newsfragments/1026.newandimproved.rst b/docs/_newsfragments/1026.newandimproved.rst new file mode 100644 index 000000000..e55eb28d7 --- /dev/null +++ b/docs/_newsfragments/1026.newandimproved.rst @@ -0,0 +1,3 @@ +The :func:`~falcon.testing.simulate_request` now suports ``msgpack`` +and returns Content-Type as ``MEDIA_MSGPACK`` in a similar way that +was made to JSON parameters. \ No newline at end of file diff --git a/falcon/testing/client.py b/falcon/testing/client.py index 77a16bf7e..cd84d58a7 100644 --- a/falcon/testing/client.py +++ b/falcon/testing/client.py @@ -56,7 +56,9 @@ from falcon.asgi_spec import ScopeType from falcon.constants import COMBINED_METHODS from falcon.constants import MEDIA_JSON +from falcon.constants import MEDIA_MSGPACK from falcon.errors import CompatibilityError +from falcon.media import MessagePackHandler from falcon.testing import helpers from falcon.testing.srmock import StartResponseMock from falcon.typing import Headers @@ -455,6 +457,7 @@ def simulate_request( content_type: Optional[str] = None, body: Optional[Union[str, bytes]] = None, json: Optional[Any] = None, + msgpack: Optional[Any] = None, file_wrapper: Optional[Callable[..., Any]] = None, wsgierrors: Optional[TextIO] = None, params: Optional[Mapping[str, Any]] = None, @@ -592,6 +595,7 @@ def simulate_request( content_type=content_type, body=body, json=json, + msgpack=msgpack, params=params, params_csv=params_csv, protocol=protocol, @@ -615,6 +619,7 @@ def simulate_request( headers, body, json, + msgpack, extras, ) @@ -667,6 +672,7 @@ async def _simulate_request_asgi( content_type: Optional[str] = ..., body: Optional[Union[str, bytes]] = ..., json: Optional[Any] = ..., + msgpack: Optional[Any] = ..., params: Optional[Mapping[str, Any]] = ..., params_csv: bool = ..., protocol: str = ..., @@ -694,6 +700,7 @@ async def _simulate_request_asgi( content_type: Optional[str] = ..., body: Optional[Union[str, bytes]] = ..., json: Optional[Any] = ..., + msgpack: Optional[Any] = ..., params: Optional[Mapping[str, Any]] = ..., params_csv: bool = ..., protocol: str = ..., @@ -724,6 +731,7 @@ async def _simulate_request_asgi( content_type: Optional[str] = None, body: Optional[Union[str, bytes]] = None, json: Optional[Any] = None, + msgpack: Optional[Any] = None, params: Optional[Mapping[str, Any]] = None, params_csv: bool = False, protocol: str = 'http', @@ -808,6 +816,13 @@ async def _simulate_request_asgi( overrides `body` and sets the Content-Type header to ``'application/json'``, overriding any value specified by either the `content_type` or `headers` arguments. + msgpack(Msgpack serializable): A Msgpack document to serialize as the + body of the request (default: ``None``). If specified, + overrides `body` and sets the Content-Type header to + ``'application/msgpack'``, overriding any value specified by + either the `content_type` or `headers` arguments. If msgpack and json + are both specified, the Content-Type header will be set as ` + `'application/msgpack'``. host(str): A string to use for the hostname part of the fully qualified request URL (default: 'falconframework.org') remote_addr (str): A string to use as the remote IP address for the @@ -846,6 +861,7 @@ async def _simulate_request_asgi( headers, body, json, + msgpack, extras, ) @@ -1551,6 +1567,13 @@ def simulate_post(app: Callable[..., Any], path: str, **kwargs: Any) -> Result: overrides `body` and sets the Content-Type header to ``'application/json'``, overriding any value specified by either the `content_type` or `headers` arguments. + msgpack(Msgpack serializable): A Msgpack document to serialize as the + body of the request (default: ``None``). If specified, + overrides `body` and sets the Content-Type header to + ``'application/msgpack'``, overriding any value specified by + either the `content_type` or `headers` arguments. If msgpack and json + are both specified, the Content-Type header will be set as + ``'application/msgpack'``. file_wrapper (callable): Callable that returns an iterable, to be used as the value for *wsgi.file_wrapper* in the WSGI environ (default: ``None``). This can be used to test @@ -1662,6 +1685,13 @@ def simulate_put(app: Callable[..., Any], path: str, **kwargs: Any) -> Result: overrides `body` and sets the Content-Type header to ``'application/json'``, overriding any value specified by either the `content_type` or `headers` arguments. + msgpack(Msgpack serializable): A Msgpack document to serialize as the + body of the request (default: ``None``). If specified, + overrides `body` and sets the Content-Type header to + ``'application/msgpack'``, overriding any value specified by + either the `content_type` or `headers` arguments. If msgpack and json + are both specified, the Content-Type header will be set as + ``'application/msgpack'``. file_wrapper (callable): Callable that returns an iterable, to be used as the value for *wsgi.file_wrapper* in the WSGI environ (default: ``None``). This can be used to test @@ -1862,6 +1892,13 @@ def simulate_patch(app: Callable[..., Any], path: str, **kwargs: Any) -> Result: overrides `body` and sets the Content-Type header to ``'application/json'``, overriding any value specified by either the `content_type` or `headers` arguments. + msgpack(Msgpack serializable): A Msgpack document to serialize as the + body of the request (default: ``None``). If specified, + overrides `body` and sets the Content-Type header to + ``'application/msgpack'``, overriding any value specified by + either the `content_type` or `headers` arguments. If msgpack and json + are both specified, the Content-Type header will be set as + ``'application/msgpack'``. host(str): A string to use for the hostname part of the fully qualified request URL (default: 'falconframework.org') remote_addr (str): A string to use as the remote IP address for the @@ -1968,6 +2005,13 @@ def simulate_delete(app: Callable[..., Any], path: str, **kwargs: Any) -> Result overrides `body` and sets the Content-Type header to ``'application/json'``, overriding any value specified by either the `content_type` or `headers` arguments. + msgpack(Msgpack serializable): A Msgpack document to serialize as the + body of the request (default: ``None``). If specified, + overrides `body` and sets the Content-Type header to + ``'application/msgpack'``, overriding any value specified by + either the `content_type` or `headers` arguments. If msgpack and json + are both specified, the Content-Type header will be set as + ``'application/msgpack'``. host(str): A string to use for the hostname part of the fully qualified request URL (default: 'falconframework.org') remote_addr (str): A string to use as the remote IP address for the @@ -2248,6 +2292,7 @@ def _prepare_sim_args( headers: Optional[HeaderArg], body: Optional[Union[str, bytes]], json: Optional[Any], + msgpack: Optional[Any], extras: Optional[Mapping[str, Any]], ) -> Tuple[ str, str, Optional[HeaderArg], Optional[Union[str, bytes]], Mapping[str, Any] @@ -2284,6 +2329,11 @@ def _prepare_sim_args( headers = dict(headers or {}) headers['Content-Type'] = MEDIA_JSON + if msgpack is not None: + body = MessagePackHandler().serialize(content_type=None, media=msgpack) + headers = dict(headers or {}) + headers['Content-Type'] = MEDIA_MSGPACK + return path, query_string, headers, body, extras diff --git a/tests/test_testing.py b/tests/test_testing.py index 89a8c49e8..17cba8b36 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -6,6 +6,13 @@ from falcon import testing from falcon.util.sync import async_to_sync +try: + import msgpack +except ImportError: + msgpack = None + +SAMPLE_BODY = testing.rand_string(0, 128 * 1024) + class CustomCookies: def items(self): @@ -104,6 +111,88 @@ def on_post(self, req, resp): assert result.text == falcon.MEDIA_JSON +@pytest.mark.skipif(not msgpack, reason='msgpack not installed') +@pytest.mark.parametrize( + 'json,msgpack,response', + [ + ({}, None, falcon.MEDIA_JSON), + (None, {}, falcon.MEDIA_MSGPACK), + ({}, {}, falcon.MEDIA_MSGPACK), + ], +) +def test_simulate_request_msgpack_content_type(json, msgpack, response): + class Foo: + def on_post(self, req, resp): + resp.text = req.content_type + + app = App() + app.add_route('/', Foo()) + + headers = {'Content-Type': falcon.MEDIA_TEXT} + + result = testing.simulate_post(app, '/', json=json, msgpack=msgpack) + assert result.text == response + + result = testing.simulate_post( + app, '/', json=json, msgpack=msgpack, content_type=falcon.MEDIA_HTML + ) + assert result.text == response + + result = testing.simulate_post( + app, '/', json=json, msgpack=msgpack, headers=headers + ) + assert result.text == response + + result = testing.simulate_post( + app, + '/', + json=json, + msgpack=msgpack, + headers=headers, + content_type=falcon.MEDIA_HTML, + ) + assert result.text == response + + +@pytest.mark.skipif(not msgpack, reason='msgpack not installed') +@pytest.mark.parametrize( + 'value', + ( + 'd\xff\xff\x00', + 'quick fox jumps over the lazy dog', + '{"hello": "WORLD!"}', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praese', + '{"hello": "WORLD!", "greetings": "fellow traveller"}', + '\xe9\xe8', + ), +) +def test_simulate_request_msgpack_different_bodies(value): + value = bytes(value, 'UTF-8') + + resource = testing.SimpleTestResource(body=value) + + app = App() + app.add_route('/', resource) + + result = testing.simulate_post(app, '/', msgpack={}) + captured_resp = resource.captured_resp + content = captured_resp.text + + if len(value) > 40: + content = value[:20] + b'...' + value[-20:] + else: + content = value + + args = [ + captured_resp.status, + captured_resp.headers['content-type'], + str(content), + ] + + expected_content = 'Result<{}>'.format(' '.join(filter(None, args))) + assert str(result) == expected_content + + @pytest.mark.parametrize('mode', ['wsgi', 'asgi', 'asgi-stream']) def test_content_type(util, mode): class Responder: