diff --git a/docs/_newsfragments/2542.newandimproved.rst b/docs/_newsfragments/2542.newandimproved.rst new file mode 100644 index 000000000..7bbf1408f --- /dev/null +++ b/docs/_newsfragments/2542.newandimproved.rst @@ -0,0 +1 @@ +New method :func:`falcon.Request.get_param_as_dict` has been added diff --git a/falcon/request.py b/falcon/request.py index 7985e6079..1ddfd9efa 100644 --- a/falcon/request.py +++ b/falcon/request.py @@ -2395,6 +2395,90 @@ def get_param_as_json( return val + def get_param_as_dict( + self, + name: str, + required: bool = False, + deep_object: bool = False, + store: StoreArg = None, + default: Any | None = None, + ) -> Any: + """Retrieve a query parameter as a dictionary. + + Supports OpenAPI's deep object style (`param[key]=value`) + and list-based key/value pairs (e.g., `param=k1,v1,k2,v2`). + + Args: + name (str): Parameter name, case-sensitive (e.g, 'sort') + + Keyword Args: + required (bool): Set to ``True`` to raise ``HTTPBadRequest`` + instead of returning ``None`` when the parameter is not found + (default ``False``). + deep_object (bool): Set to True to use the deepObject (as in OAS) + format. + store: A ``dict``-like object in which to place the value + of the param, but only if the param is found (default ``None``). + default (any): If the param is not found returns the + given value instead of ``None`` + + Returns: + dict: The value of the param if it is found. Otherwise, returns + ``None`` unless required is ``True``. + + Raises: + HTTPBadRequest: A required param is missing from the request, or + the value could not be parsed from the parameter. + + .. versionadded:: 4.1.0 + """ + + output: dict[str, Any] | None = None + + if deep_object: + oc: dict[str, Any] = {} + for key, value in self._params.items(): + if not key.startswith(f'{name}[') and key.endswith(']'): + continue + inner = key[len(name) + 1 : -1] + + if isinstance(value, (list, tuple)): + oc[inner] = value[0] if value else None + else: + oc[inner] = value + + if not oc: + if required: + msg = 'Missing deep object parameter' + raise errors.HTTPMissingParam(msg) + output = default + else: + output = oc + + else: + try: + values_list = self.get_param_as_list(name) + except errors.HTTPBadRequest: + msg = 'It could not parse the query parameter' + raise errors.HTTPInvalidParam(msg, name) from None + + if values_list is None: + if required: + msg = 'Missing query parameter' + raise errors.HTTPMissingParam(msg) + output = default + + else: + if len(values_list) % 2 != 0: + msg = 'Invalid parameter format, list elements must be even' + raise errors.HTTPInvalidParam(msg, name) + output = dict(zip(values_list[::2], values_list[1::2])) + + if store is not None and isinstance(output, dict): + store.update(output) + + return output + def has_param(self, name: str) -> bool: """Determine whether or not the query string parameter already exists. diff --git a/tests/test_request_params.py b/tests/test_request_params.py new file mode 100644 index 000000000..1a958d2ef --- /dev/null +++ b/tests/test_request_params.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from falcon import errors +from falcon.request import Request + + +class DummyRequestParams: + def __init__(self, params: dict[str, Any]): + self._params = params + + def get_param_as_list(self, name: str): + if name == 'bad': + raise errors.HTTPBadRequest() + return self._params.get(name) + + def get_param_as_dict( + self, + name: str, + required: bool = False, + deep_object: bool = False, + store: dict[str, Any] | None = None, + default: Any | None = None, + ) -> Any: ... + + +# NOTE(StepanUFL): If a better way to make this play well with mypy exists... +DummyRequestParams.get_param_as_dict = Request.get_param_as_dict # type: ignore[method-assign, assignment] + + +@pytest.mark.parametrize( + 'params,expected', + [ + ({'user[name]': 'Ash', 'user[age]': '36'}, {'name': 'Ash', 'age': '36'}), + ({'user[empty]': ''}, {'empty': ''}), + ], +) +def test_deep_object_success(params, expected): + req = DummyRequestParams(params) + result = req.get_param_as_dict('user', deep_object=True) + assert result == expected + + +def test_deep_object_missing_required(): + req = DummyRequestParams({}) + with pytest.raises(errors.HTTPMissingParam): + req.get_param_as_dict('user', deep_object=True, required=True) + + +def test_deep_object_default_used_when_missing(): + req = DummyRequestParams({}) + default = {'fallback': 123} + result = req.get_param_as_dict('user', deep_object=True, default=default) + assert result == default + + +def test_regular_param_success_even_length(): + req = DummyRequestParams({'pair': ['a', '1', 'b', '2']}) + result = req.get_param_as_dict('pair') + assert result == {'a': '1', 'b': '2'} + + +def test_regular_param_missing_required(): + req = DummyRequestParams({}) + with pytest.raises(errors.HTTPMissingParam): + req.get_param_as_dict('pair', required=True) + + +def test_regular_param_odd_length_raises_invalid(): + req = DummyRequestParams({'pair': ['a', 'b', 'c']}) + with pytest.raises(errors.HTTPInvalidParam): + req.get_param_as_dict('pair') + + +def test_regular_param_default_used_when_missing(): + req = DummyRequestParams({}) + result = req.get_param_as_dict('pair', default={'x': 'y'}) + assert result == {'x': 'y'} + + +def test_regular_param_bad_request_raises_invalid(): + req = DummyRequestParams({'ok': ['1', '2']}) + with pytest.raises(errors.HTTPInvalidParam): + req.get_param_as_dict('bad') + + +def test_store_argument_is_updated(): + req = DummyRequestParams({'pair': ['a', '1', 'b', '2']}) + store = {} + result = req.get_param_as_dict('pair', store=store) + assert result == {'a': '1', 'b': '2'} + assert store == {'a': '1', 'b': '2'} + + +def test_deep_object_with_list_values(): + req = DummyRequestParams({'user[name]': ['Bond'], 'user[id]': ['007']}) + result = req.get_param_as_dict('user', deep_object=True) + assert result == {'name': 'Bond', 'id': '007'} + + +@pytest.mark.skip(reason='Delimiter functionality not implemented') +def test_regular_param_with_delimiter_argument_is_ignored_but_accepted(): + req = DummyRequestParams({'pair': ['a', '1', 'b', '2']}) + result = req.get_param_as_dict('pair') + assert result == {'a': '1', 'b': '2'} + + +def test_deep_object_skips_non_matching_bracketed_keys(): + params = { + 'user[name]': 'Ash', + 'weird]': 'looking', + } + req = DummyRequestParams(params) + result = req.get_param_as_dict('user', deep_object=True) + assert result == {'name': 'Ash'}