Skip to content
Open
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 docs/_newsfragments/2538.newandimproved.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
req.get_param_as_list() now supports a delimiter argument for splitting values.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nicer to actually link to the request method in question, probably the below could do (untested)

:meth:`falcon.Request.get_param_as_list`

21 changes: 21 additions & 0 deletions falcon/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -1943,6 +1943,7 @@ def get_param_as_list(
*,
required: Literal[True],
store: StoreArg = ...,
delimiter: str | None = None,
default: list[str] | None = ...,
) -> list[str]: ...

Expand All @@ -1953,6 +1954,7 @@ def get_param_as_list(
transform: Callable[[str], _T],
required: Literal[True],
store: StoreArg = ...,
delimiter: str | None = None,
default: list[_T] | None = ...,
) -> list[_T]: ...

Expand All @@ -1963,6 +1965,7 @@ def get_param_as_list(
transform: None = ...,
required: bool = ...,
store: StoreArg = ...,
delimiter: str | None = None,
*,
default: list[str],
) -> list[str]: ...
Expand All @@ -1974,6 +1977,7 @@ def get_param_as_list(
transform: Callable[[str], _T],
required: bool = ...,
store: StoreArg = ...,
delimiter: str | None = None,
*,
default: list[_T],
) -> list[_T]: ...
Expand All @@ -1985,6 +1989,7 @@ def get_param_as_list(
transform: None = ...,
required: bool = ...,
store: StoreArg = ...,
delimiter: str | None = None,
default: list[str] | None = ...,
) -> list[str] | None: ...

Expand All @@ -1995,6 +2000,7 @@ def get_param_as_list(
transform: Callable[[str], _T],
required: bool = ...,
store: StoreArg = ...,
delimiter: str | None = None,
default: list[_T] | None = ...,
) -> list[_T] | None: ...

Expand All @@ -2004,6 +2010,7 @@ def get_param_as_list(
transform: Callable[[str], _T] | None = None,
required: bool = False,
store: StoreArg = None,
delimiter: str | None = None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add the delimiter parameter last. We haven't enforced keyword-only arguments in this method yet (maybe we should 🤔), so this could otherwise theoretically be perceived as a breaking change if one passes everything as positional arguments.

default: list[_T] | None = None,
) -> list[_T] | list[str] | None:
"""Return the value of a query string parameter as a list.
Expand All @@ -2021,6 +2028,10 @@ def get_param_as_list(
name (str): Parameter name, case-sensitive (e.g., 'ids').

Keyword Args:
delimiter(str): An optional character for splitting a parameter
value into a list. Useful for styles like ``spaceDelimited``
or ``pipeDelimited``. If not provided, default list parsing
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be useful to link to the OpenAPI spec in the parentheses (see also: ...).

applies.
transform (callable): An optional transform function
that takes as input each element in the list as a ``str`` and
outputs a transformed element for inclusion in the list that
Expand Down Expand Up @@ -2066,6 +2077,16 @@ def get_param_as_list(
if name in params:
items = params[name]

# If a delimiter is specified AND the param is a single string, split it.
if delimiter and isinstance(items, str):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's only allow the delimiters from the OpenAPI spec in the first iteration, so ',', ' ', and '|'.
This will make this change easier to reason about in the terms of possible edge cases and side effects.

Copy link
Member

@CaselIT CaselIT Oct 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it may makes sense to use a dict like similar to the one below to validate delimiter

{' ': ' ', 'spaceDelimited': ' ', ',': ',', 'commaDelimited': ',', '|': '|', 'pipeDelimited': '|'}

(we could likely define this at module level)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(not sure if we want to also support comma, space, pipe as valid keys. I'm not against it, but they are not in the spec)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I would vote for keeping just the bare minimum of supported delimiters needed for the OAS in the first iteration (so just like your mapping, maybe even sans commaDelimited since it is not in the spec either, but comma is already default in some compact styles there).

We can add more later if there is need/popular demand.

if delimiter == ' ':
items = items.split(' ')
elif delimiter == '|':
items = items.split('|')
else:
# For commas and others, also strip whitespace for convenience.
items = [v.strip() for v in items.split(delimiter)]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we want undocumented side effects like stripping whitespace, moreover, depending on the value of the delimiter.


# NOTE(warsaw): When a key appears multiple times in the request
# query, it will already be represented internally as a list.
# NOTE(kgriffs): Likewise for comma-delimited values.
Expand Down
29 changes: 29 additions & 0 deletions tests/test_request_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,35 @@ def test_etag_parsing_helper(self, asgi, header_value):

assert _parse_etags(header_value) is None

@pytest.mark.parametrize('asgi', [False, True])
def test_get_param_as_list_space_delimited(self, asgi):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we already a fixture called asgi that automatically does true/false

req = create_req(asgi, query_string='names=Luke%20Leia%20Han')
result = req.get_param_as_list('names', delimiter=' ')
assert result == ['Luke', 'Leia', 'Han']

@pytest.mark.parametrize('asgi', [False, True])
def test_get_param_as_list_pipe_delimited(self, asgi):
req = create_req(asgi, query_string='names=Luke|Leia|Han')
result = req.get_param_as_list('names', delimiter='|')
assert result == ['Luke', 'Leia', 'Han']

@pytest.mark.parametrize('asgi', [False, True])
def test_get_param_as_list_custom_delimiter(self, asgi):
req = create_req(asgi, query_string='names=Luke;Leia;Han')
result = req.get_param_as_list('names', delimiter=';')
assert result == ['Luke', 'Leia', 'Han']

@pytest.mark.parametrize('asgi', [False, True])
def test_get_param_as_list_no_delimiter_still_defaults_to_comma(self, asgi):
options = falcon.RequestOptions()
options.auto_parse_qs_csv = True

req = create_req(asgi, query_string='names=Luke,Leia,Han', options=options)

result = req.get_param_as_list('names', delimiter=None)

assert result == ['Luke', 'Leia', 'Han']

# -------------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------------
Expand Down