diff --git a/AUTHORS b/AUTHORS index e7513e4a1..6e8ba7c23 100644 --- a/AUTHORS +++ b/AUTHORS @@ -227,6 +227,9 @@ listed below by date of first contribution: * rikka0612 (ReinerBRO) * tuanaiseo * Het Patel (CuriousHet) +* Stepan Pechenkin (StepanUFL) +* Tang Vu (tang-vu) +* Arthur Prioli (arthurprioli) (et al.) diff --git a/docs/_newsfragments/1026.newandimproved.rst b/docs/_newsfragments/1026.newandimproved.rst index e55eb28d7..3db828597 100644 --- a/docs/_newsfragments/1026.newandimproved.rst +++ b/docs/_newsfragments/1026.newandimproved.rst @@ -1,3 +1,5 @@ -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 +The test client (:func:`~falcon.testing.simulate_request` and friends) now +accepts a ``msgpack`` keyword argument, analogous to the existing ``json`` +one. When provided, the value is serialized as a MessagePack document and +used as the request body, and the ``Content-Type`` header is set to +:attr:`~falcon.MEDIA_MSGPACK`. diff --git a/docs/_newsfragments/2071.newandimproved.rst b/docs/_newsfragments/2071.newandimproved.rst index 829222262..01463c732 100644 --- a/docs/_newsfragments/2071.newandimproved.rst +++ b/docs/_newsfragments/2071.newandimproved.rst @@ -1,4 +1,5 @@ -A new router option, :attr:`~.CompiledRouterOptions.default_to_on_request`, was +A new router option, +:attr:`~falcon.routing.CompiledRouterOptions.default_to_on_request`, was added to allow resources to provide a default responder via ``on_request()`` (disabled by default). When enabled, ``on_request()`` is used as the default responder for every unimplemented HTTP diff --git a/docs/_newsfragments/2546.newandimproved.rst b/docs/_newsfragments/2546.newandimproved.rst index e92f45c29..bceb2d4f0 100644 --- a/docs/_newsfragments/2546.newandimproved.rst +++ b/docs/_newsfragments/2546.newandimproved.rst @@ -1,6 +1,6 @@ -Added a new :meth:`~falcon.Request.get_query_string_as_media` method to -:class:`~falcon.Request` and :class:`~falcon.asgi.Request` that deserializes -the entire query string as media content. This is useful for implementing -OpenAPI 3.2 ``Parameter Object`` with content, where the whole query string -is treated as serialized data (e.g., JSON). The method URL-decodes the query -string and deserializes it using the configured media handlers. +A new :meth:`~falcon.Request.get_query_string_as_media` method was added to +:class:`~falcon.Request`. The method URL-decodes the entire query string and +deserializes it using the configured media handlers. This is useful for +implementing the OpenAPI 3.2 `querystring parameter location +`__, where +the entire query string is treated as a single serialized value. diff --git a/docs/_newsfragments/2549.newandimproved.rst b/docs/_newsfragments/2549.newandimproved.rst index b4cb22536..0e88d81cf 100644 --- a/docs/_newsfragments/2549.newandimproved.rst +++ b/docs/_newsfragments/2549.newandimproved.rst @@ -1,4 +1,5 @@ A new :meth:`~falcon.Request.get_param_as_media` method was added to -:class:`~falcon.Request` that deserializes a single query-string parameter -using the configured media handlers. The helper accepts an optional -``media_type`` (which falls back to the app's ``default_media_type``). +:class:`~falcon.Request`. It deserializes a single query-string parameter +using the configured media handlers, and accepts an optional ``media_type`` +that falls back to the app's +:attr:`~falcon.RequestOptions.default_media_type` when unspecified. diff --git a/docs/_newsfragments/2630.misc.rst b/docs/_newsfragments/2630.misc.rst index 8ba1381a1..5903b4bec 100644 --- a/docs/_newsfragments/2630.misc.rst +++ b/docs/_newsfragments/2630.misc.rst @@ -1,4 +1,4 @@ ``falcon.sys`` (an internal re-export of the standard library's -:mod:`sys` module that was added by oversight long ago) is +:mod:`sys` module that was added inadvertently long ago) is scheduled for removal in Falcon 5.0. Import the standard library's :mod:`sys` module directly instead. diff --git a/docs/changes/4.3.0.rst b/docs/changes/4.3.0.rst index 7785f05f3..11e33a401 100644 --- a/docs/changes/4.3.0.rst +++ b/docs/changes/4.3.0.rst @@ -24,12 +24,17 @@ Many thanks to all of our talented and stylish contributors for this release! - `0x1618 `__ - `0xMattB `__ +- `arthurprioli `__ +- `CaselIT `__ - `CuriousHet `__ - `gespyrop `__ - `granuels `__ - `jap `__ +- `MannXo `__ - `ReinerBRO `__ - `rushevich `__ +- `StepanUFL `__ +- `tang-vu `__ - `thisisrick25 `__ - `toroleapinc `__ - `tuanaiseo `__ diff --git a/falcon/request.py b/falcon/request.py index ec4d77148..4e6fc9d5d 100644 --- a/falcon/request.py +++ b/falcon/request.py @@ -1175,13 +1175,14 @@ def get_query_string_as_media( This method URL-decodes the query string and then deserializes it as a media object using the specified media type handler. This is - useful for implementing OpenAPI 3.2 `Parameter Object with content`_ - where the entire query string is treated as serialized content. + useful for implementing the OpenAPI 3.2 `querystring parameter + location`_, where the entire query string is treated as a single + serialized value (typically JSON or form-urlencoded). For example, if the query string is ``%7B%22numbers%22%3A%5B1%2C2%5D%2C%22flag%22%3Anull%7D``, this method will URL-decode it to ``{"numbers":[1,2],"flag":null}`` and - then deserialize it as JSON (assuming ``media_type`` is set to + then deserialize it as JSON (assuming `media_type` is set to ``'application/json'``):: # Query string: ?%7B%22numbers%22%3A%5B1%2C2%5D%7D @@ -1195,13 +1196,13 @@ def get_query_string_as_media( let the media handler try to deserialize the empty string and will return the value returned by the handler or propagate the exception raised by it. To instead return a different value in case of an - exception by the handler, specify the argument ``default_when_empty``. + exception by the handler, specify the argument `default_when_empty`. Args: - media_type: Media type to use for deserialization - (e.g., ``'application/json'``). If not specified, the - ``default_media_type`` from :class:`falcon.RequestOptions` - will be used (default ``'application/json'``). + media_type: Media type to use for deserialization (e.g., + ``'application/json'``). If ``None``, falls back to the + value of :attr:`~falcon.RequestOptions.default_media_type` + (default ``'application/json'``). Keyword Args: default_when_empty: Fallback value to return when there is no @@ -1213,12 +1214,10 @@ def get_query_string_as_media( object: The deserialized media representation of the query string. Raises: - ValueError: No media handler is configured for the specified - media type. - - .. _Parameter Object with content: - https://spec.openapis.org/oas/latest.html#parameter-object-examples + ValueError: No media handler is configured for `media_type`. + .. _querystring parameter location: + https://spec.openapis.org/oas/v3.2.0.html#parameter-locations """ if media_type is None: media_type = self.options.default_media_type @@ -2456,13 +2455,21 @@ def get_param_as_json( the value could not be parsed as JSON. """ - # NOTE(mannxo): Delegate to the more general get_param_as_media implementation. - return self.get_param_as_media( - name, - media_type=MEDIA_JSON, - required=required, - store=store, - default=default, + param_value = self.get_param(name, required=required) + if param_value is None: + return default + + handler, _, _ = self.options.media_handlers._resolve( + MEDIA_JSON, MEDIA_JSON, raise_not_found=False + ) + # NOTE(vytas): Fall back to a default JSON handler so that this legacy + # helper keeps working even when the user has unregistered the + # built-in JSON handler. + if handler is None: + handler = _DEFAULT_JSON_HANDLER + + return self._deserialize_param_value( + name, param_value, MEDIA_JSON, handler, store ) def get_param_as_media( @@ -2475,15 +2482,21 @@ def get_param_as_media( ) -> Any: """Return a query string parameter's value deserialized by a media handler. + This is useful for implementing the OpenAPI Parameter Object's + `content`_ field, where an individual query-string parameter is + itself a serialized media document such as JSON. + Args: name (str): Parameter name, case-sensitive (e.g., 'payload'). Keyword Args: - media_type (str | None): Media type to use for deserialization. If - ``None``, falls back to the app's ``default_media_type``. - required (bool): Set to ``True`` to raise ``HTTPBadRequest`` - instead of returning ``None`` when the parameter is not - found (default ``False``). + media_type (str): Media type to use for deserialization (e.g., + ``'application/json'``). If ``None``, falls back to the + value of :attr:`~falcon.RequestOptions.default_media_type` + (default ``'application/json'``). + required (bool): Set to ``True`` to raise + :class:`~falcon.HTTPBadRequest` instead of returning ``None`` + when the parameter is not found (default ``False``). store (dict): A ``dict``-like object in which to place the value of the param, but only if the param is found (default ``None``). @@ -2492,37 +2505,46 @@ def get_param_as_media( Returns: The deserialized value for the parameter, or ``default`` if the - parameter is missing and ``required`` is ``False``. + parameter is missing and `required` is ``False``. Raises: HTTPBadRequest: A required param is missing from the request, or the value could not be parsed by the selected media handler. + ValueError: No media handler is configured for `media_type`. + + .. _content: + https://spec.openapis.org/oas/latest.html#fixed-fields-for-use-with-content """ param_value = self.get_param(name, required=required) - if param_value is None: return default - # Resolve media handler if media_type is None: - # Fall back to the app's default media type. media_type = self.options.default_media_type handler, _, _ = self.options.media_handlers._resolve( media_type, self.options.default_media_type, raise_not_found=False ) if handler is None: - # NOTE(mannxo): Substring match is intentional; covers variants - # like 'application/json; charset=utf-8' and is good enough in - # practice until a stricter check is warranted. - if media_type and MEDIA_JSON in media_type: - handler = _DEFAULT_JSON_HANDLER - else: - raise errors.HTTPInternalServerError( - title=f'No media handler exists for "{media_type}"' - ) + raise ValueError( + f'No media handler is configured for {media_type!r}. ' + 'Please ensure the media type is registered in ' + 'RequestOptions.media_handlers.' + ) + + return self._deserialize_param_value( + name, param_value, media_type, handler, store + ) + def _deserialize_param_value( + self, + name: str, + param_value: str, + media_type: str, + handler: Any, + store: StoreArg, + ) -> Any: try: # TODO(CaselIT): find a way to avoid encode + BytesIO if handlers # interface is refactored. Possibly using the WS interface? @@ -2531,7 +2553,7 @@ def get_param_as_media( ) except errors.HTTPBadRequest: raise errors.HTTPInvalidParam( - f'It could not be deserialized as "{media_type}".', name + f'It could not be deserialized as {media_type!r}.', name ) if store is not None: diff --git a/falcon/testing/client.py b/falcon/testing/client.py index 55212d814..ee071196d 100644 --- a/falcon/testing/client.py +++ b/falcon/testing/client.py @@ -600,13 +600,13 @@ def simulate_request( iterable yielding a series of two-member (*name*, *value*) iterables. Each pair of items provides the name and value for the 'Set-Cookie' header. - 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'``. + msgpack (MessagePack serializable): A MessagePack document to + serialize as the body of the request (default: ``None``). If + specified, overrides `body` and sets the Content-Type header + to :attr:`~falcon.MEDIA_MSGPACK`, overriding any value + specified by either the `content_type` or `headers` + arguments. If both `msgpack` and `json` are specified, + `msgpack` takes precedence. Returns: :class:`~.Result`: The result of the request @@ -868,13 +868,13 @@ async def _simulate_request_asgi( iterable yielding a series of two-member (*name*, *value*) iterables. Each pair of items provides the name and value for the 'Set-Cookie' header. - 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'``. + msgpack (MessagePack serializable): A MessagePack document to + serialize as the body of the request (default: ``None``). If + specified, overrides `body` and sets the Content-Type header + to :attr:`~falcon.MEDIA_MSGPACK`, overriding any value + specified by either the `content_type` or `headers` + arguments. If both `msgpack` and `json` are specified, + `msgpack` takes precedence. Returns: :class:`~.Result`: The result of the request @@ -1632,13 +1632,13 @@ def simulate_post(app: Callable[..., Any], path: str, **kwargs: Any) -> Result: iterable yielding a series of two-member (*name*, *value*) iterables. Each pair of items provides the name and value for the 'Set-Cookie' header. - 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'``. + msgpack (MessagePack serializable): A MessagePack document to + serialize as the body of the request (default: ``None``). If + specified, overrides `body` and sets the Content-Type header + to :attr:`~falcon.MEDIA_MSGPACK`, overriding any value + specified by either the `content_type` or `headers` + arguments. If both `msgpack` and `json` are specified, + `msgpack` takes precedence. Returns: :class:`~.Result`: The result of the request @@ -1750,13 +1750,13 @@ def simulate_put(app: Callable[..., Any], path: str, **kwargs: Any) -> Result: iterable yielding a series of two-member (*name*, *value*) iterables. Each pair of items provides the name and value for the 'Set-Cookie' header. - 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'``. + msgpack (MessagePack serializable): A MessagePack document to + serialize as the body of the request (default: ``None``). If + specified, overrides `body` and sets the Content-Type header + to :attr:`~falcon.MEDIA_MSGPACK`, overriding any value + specified by either the `content_type` or `headers` + arguments. If both `msgpack` and `json` are specified, + `msgpack` takes precedence. Returns: :class:`~.Result`: The result of the request @@ -1952,13 +1952,13 @@ def simulate_patch(app: Callable[..., Any], path: str, **kwargs: Any) -> Result: iterable yielding a series of two-member (*name*, *value*) iterables. Each pair of items provides the name and value for the 'Set-Cookie' header. - 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'``. + msgpack (MessagePack serializable): A MessagePack document to + serialize as the body of the request (default: ``None``). If + specified, overrides `body` and sets the Content-Type header + to :attr:`~falcon.MEDIA_MSGPACK`, overriding any value + specified by either the `content_type` or `headers` + arguments. If both `msgpack` and `json` are specified, + `msgpack` takes precedence. Returns: :class:`~.Result`: The result of the request @@ -2065,13 +2065,13 @@ def simulate_delete(app: Callable[..., Any], path: str, **kwargs: Any) -> Result iterable yielding a series of two-member (*name*, *value*) iterables. Each pair of items provides the name and value for the 'Set-Cookie' header. - 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'``. + msgpack (MessagePack serializable): A MessagePack document to + serialize as the body of the request (default: ``None``). If + specified, overrides `body` and sets the Content-Type header + to :attr:`~falcon.MEDIA_MSGPACK`, overriding any value + specified by either the `content_type` or `headers` + arguments. If both `msgpack` and `json` are specified, + `msgpack` takes precedence. Returns: :class:`~.Result`: The result of the request diff --git a/tests/test_query_params.py b/tests/test_query_params.py index 8bdf33972..5052a647e 100644 --- a/tests/test_query_params.py +++ b/tests/test_query_params.py @@ -7,7 +7,6 @@ import pytest import falcon -from falcon.errors import HTTPInternalServerError from falcon.errors import HTTPInvalidParam from falcon.errors import HTTPMissingParam from falcon.errors import MediaMalformedError @@ -1027,28 +1026,11 @@ def test_get_param_as_media_default_media_type( # Call without media_type to trigger fallback to default_media_type assert req.get_param_as_media('payload') == payload_dict - def test_get_param_as_media_no_handler_json_fallback( - self, simulate_request, client, resource - ): + def test_get_param_as_media_no_handler(self, simulate_request, client, resource): client.app.add_route('/', resource) - payload_dict = {'foo': 'bar'} - query_string = f'payload={json.dumps(payload_dict)}' - simulate_request(client=client, path='/', query_string=query_string) - req = resource.captured_req - # Use a media type that contains 'application/json' substring - # but has no explicit handler - result = req.get_param_as_media('payload', media_type='custom/application/json') - assert result == payload_dict - - def test_get_param_as_media_no_handler_no_json( - self, simulate_request, client, resource - ): - client.app.add_route('/', resource) - query_string = 'payload=test' - simulate_request(client=client, path='/', query_string=query_string) + simulate_request(client=client, path='/', query_string='payload=test') req = resource.captured_req - # Use a media type that won't have a handler and doesn't contain 'json' - with pytest.raises(HTTPInternalServerError): + with pytest.raises(ValueError, match='No media handler is configured'): req.get_param_as_media('payload', media_type='application/xml') def test_get_param_as_media_yaml( @@ -1082,7 +1064,7 @@ def test_get_param_as_media_no_default_handler( simulate_request(client=client, path='/', query_string='data={k1:+1,k2:+true}') req = resource.captured_req - with pytest.raises(HTTPInternalServerError): + with pytest.raises(ValueError, match='No media handler is configured'): req.get_param_as_media('data') def test_has_param(self, simulate_request, client, resource): diff --git a/tests/test_testing.py b/tests/test_testing.py index b4f6c2e52..384aa6e2e 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -13,8 +13,6 @@ except ImportError: msgpack = None -SAMPLE_BODY = testing.rand_string(0, 128 * 1024) - class CustomCookies: def items(self): @@ -113,16 +111,27 @@ def on_post(self, req, resp): assert result.text == falcon.MEDIA_JSON -@pytest.mark.skipif(not msgpack, reason='msgpack not installed') +@pytest.mark.skipif(msgpack is None, reason='msgpack is not installed') @pytest.mark.parametrize( - 'json,msgpack,response', + 'json_doc, msgpack_doc, expected_content_type', [ ({}, None, falcon.MEDIA_JSON), (None, {}, falcon.MEDIA_MSGPACK), ({}, {}, falcon.MEDIA_MSGPACK), ], ) -def test_simulate_request_msgpack_content_type(json, msgpack, response): +@pytest.mark.parametrize( + 'content_type, headers', + [ + (None, None), + (falcon.MEDIA_HTML, None), + (None, {'Content-Type': falcon.MEDIA_TEXT}), + (falcon.MEDIA_HTML, {'Content-Type': falcon.MEDIA_TEXT}), + ], +) +def test_simulate_request_msgpack_content_type( + json_doc, msgpack_doc, expected_content_type, content_type, headers +): class Foo: def on_post(self, req, resp): resp.text = req.content_type @@ -130,69 +139,78 @@ def on_post(self, req, resp): 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, + json=json_doc, + msgpack=msgpack_doc, + content_type=content_type, headers=headers, - content_type=falcon.MEDIA_HTML, ) - assert result.text == response + assert result.text == expected_content_type -@pytest.mark.skipif(not msgpack, reason='msgpack not installed') +@pytest.mark.skipif(msgpack is None, reason='msgpack is 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', - ), + 'media', + [ + True, + 42, + 3.14, + 'Hello, World!', + b'binary string', + [1, 2, 3, 'four'], + {'key': 'value'}, + {'nested': {'list': [1, 2, 3], 'unicode': 'caf\xe9 \U0001f600'}}, + ], ) -def test_simulate_request_msgpack_different_bodies(value): - value = bytes(value, 'UTF-8') +def test_simulate_request_msgpack_body(asgi, util, media): + captured = {} - resource = testing.SimpleTestResource(body=value) + if asgi: - app = App() - app.add_route('/', resource) + class Resource: + async def on_post(self, req, resp): + captured['body'] = await req.stream.read() + + else: + + class Resource: + def on_post(self, req, resp): + captured['body'] = req.bounded_stream.read() - result = testing.simulate_post(app, '/', msgpack={}) - captured_resp = resource.captured_resp - content = captured_resp.text + app = util.create_app(asgi) + app.add_route('/', Resource()) + + result = testing.simulate_post(app, '/', msgpack=media) + + assert result.status_code == 200 + assert captured['body'] == msgpack.packb(media, use_bin_type=True) + assert msgpack.unpackb(captured['body'], raw=False) == media + + +@pytest.mark.skipif(msgpack is None, reason='msgpack is not installed') +def test_simulate_request_msgpack_overrides_body(asgi, util): + captured = {} + + if asgi: + + class Resource: + async def on_post(self, req, resp): + captured['body'] = await req.stream.read() - if len(value) > 40: - content = value[:20] + b'...' + value[-20:] else: - content = value - args = [ - captured_resp.status, - captured_resp.headers['content-type'], - str(content), - ] + class Resource: + def on_post(self, req, resp): + captured['body'] = req.bounded_stream.read() + + app = util.create_app(asgi) + app.add_route('/', Resource()) + + testing.simulate_post(app, '/', body='ignored', msgpack={'real': True}) - expected_content = 'Result<{}>'.format(' '.join(filter(None, args))) - assert str(result) == expected_content + assert captured['body'] == msgpack.packb({'real': True}, use_bin_type=True) @pytest.mark.parametrize('mode', ['wsgi', 'asgi', 'asgi-stream'])