Repository: litestar-org/litestar

Issue:
Query parameter arrays with 1 element aren't parsed as arrays
I've been reading through the docs and so far I'm loving how domain modelling is built in with pydantic! Experimenting with query params I noticed that query parameter arrays with 1 element end up with type `T` rather than `List[T]`.  See the example below:

```
from typing import List
from starlite import get, Starlite

@get("/test")
async def test(myArray: List[int]) -> int:
    return 0

app = Starlite(route_handlers=[test])
```

Running 
- `curl "localhost:8000/test?myArray=1"` gives a 400 with `value is not a valid list`. 
- `curl "localhost:8000/test?myArray=1&myArray=2` is fine.  

The workaround isn't too bad - declare myArray as `Union[int, List[int]]` and handle the different types in the body of the function, but I wonder if from an ergonomics perspective its nicer to wrap that logic into the library itself? Something like

```
from pydantic.generics import GenericModel
from typing import List, Generic, TypeVar

DataT = TypeVar('DataT')

class QueryParamArray(GenericModel, Generic[DataT]):
    __root__: Union[DataT, List[DataT]] 

    @property
    def array(self):
        return self.__root__ if isinstance(self.__root__, list) else [self.__root__]
```

Then it would be a simple matter of declaring `myArray` as a `QueryParamArray`. Happy to raise a PR to include this but am not sure where the best place to do so is (types.py?). Also happy to be told this is a stupid idea and its up to the users to declare this utility themselves.

Thanks in advance


Discussion:
Hi, thanks for reporting. 

Yes this is a bug. We should infer the type and stick with it. Do you want to submit a PR? We will take care of it otherwise. 
> Hi, thanks for reporting.
> 
> Yes this is a bug. We should infer the type and stick with it. Do you want to submit a PR? We will take care of it otherwise.

Is this open to pick up ?
lets let @josepdaniel respond first. If he doesn't respond within the next few hours, go ahead. 

Also - there wouldn't need to be much adjustment, just a tuning of the parsing logic, which now converts from a list to a single element if the length is one. 
@Goldziher sure, I'm happy to have a crack at it. I've just started looking at the repo so may take me a few days to get familiar and merge a fix, hope thats alright. 
> @Goldziher sure, I'm happy to have a crack at it. I've just started looking at the repo so may take me a few days to get familiar and merge a fix, hope thats alright. 

Take your time

Resolving patch:
diff --git a/starlite/kwargs.py b/starlite/kwargs.py
--- a/starlite/kwargs.py
+++ b/starlite/kwargs.py
@@ -1,6 +1,17 @@
 from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple, Type, Union, cast
 
-from pydantic.fields import FieldInfo, ModelField, Undefined
+from pydantic.fields import (
+    SHAPE_DEQUE,
+    SHAPE_FROZENSET,
+    SHAPE_LIST,
+    SHAPE_SEQUENCE,
+    SHAPE_SET,
+    SHAPE_TUPLE,
+    SHAPE_TUPLE_ELLIPSIS,
+    FieldInfo,
+    ModelField,
+    Undefined,
+)
 
 from starlite.connection import Request, WebSocket
 from starlite.constants import (
@@ -15,6 +26,17 @@
 from starlite.signature import SignatureModel, get_signature_model
 from starlite.types import ReservedKwargs
 
+# Shapes corresponding to sequences
+SEQ_SHAPES = {
+    SHAPE_LIST,
+    SHAPE_SET,
+    SHAPE_SEQUENCE,
+    SHAPE_TUPLE,
+    SHAPE_TUPLE_ELLIPSIS,
+    SHAPE_DEQUE,
+    SHAPE_FROZENSET,
+}
+
 
 class ParameterDefinition(NamedTuple):
     param_type: ParamType
@@ -22,6 +44,7 @@ class ParameterDefinition(NamedTuple):
     field_alias: str
     is_required: bool
     default_value: Any
+    is_sequence: bool
 
 
 class Dependency:
@@ -65,6 +88,7 @@ class KwargsModel:
         "expected_path_params",
         "expected_query_params",
         "expected_reserved_kwargs",
+        "sequence_query_parameter_names",
     )
 
     def __init__(
@@ -77,6 +101,7 @@ def __init__(
         expected_path_params: Set[ParameterDefinition],
         expected_query_params: Set[ParameterDefinition],
         expected_reserved_kwargs: Set[ReservedKwargs],
+        sequence_query_parameter_names: Set[str],
     ) -> None:
         self.expected_cookie_params = expected_cookie_params
         self.expected_dependencies = expected_dependencies
@@ -85,6 +110,7 @@ def __init__(
         self.expected_path_params = expected_path_params
         self.expected_query_params = expected_query_params
         self.expected_reserved_kwargs = expected_reserved_kwargs
+        self.sequence_query_parameter_names = sequence_query_parameter_names
         self.has_kwargs = (
             expected_cookie_params
             or expected_dependencies
@@ -110,10 +136,7 @@ def create_dependency_graph(cls, key: str, dependencies: Dict[str, Provide]) ->
 
     @staticmethod
     def create_parameter_definition(
-        allow_none: bool,
-        field_info: FieldInfo,
-        field_name: str,
-        path_parameters: Set[str],
+        allow_none: bool, field_info: FieldInfo, field_name: str, path_parameters: Set[str], is_sequence: bool
     ) -> ParameterDefinition:
         """
         Creates a ParameterDefition for the given pydantic FieldInfo instance and inserts it into the correct parameter set
@@ -141,6 +164,7 @@ def create_parameter_definition(
             field_alias=field_alias,
             default_value=default_value,
             is_required=is_required and (default_value is None and not allow_none),
+            is_sequence=is_sequence,
         )
 
     @classmethod
@@ -180,6 +204,7 @@ def create_for_signature_model(
                     field_name=field_name,
                     field_info=model_field.field_info,
                     path_parameters=path_parameters,
+                    is_sequence=model_field.shape in SEQ_SHAPES,
                 )
                 for field_name, model_field in layered_parameters.items()
                 if field_name not in ignored_keys and field_name not in signature_model.__fields__
@@ -190,6 +215,7 @@ def create_for_signature_model(
                     field_name=field_name,
                     field_info=model_field.field_info,
                     path_parameters=path_parameters,
+                    is_sequence=model_field.shape in SEQ_SHAPES,
                 )
                 for field_name, model_field in signature_model.__fields__.items()
                 if field_name not in ignored_keys and field_name not in layered_parameters
@@ -220,6 +246,7 @@ def create_for_signature_model(
                     field_name=field_name,
                     field_info=field_info,
                     path_parameters=path_parameters,
+                    is_sequence=model_field.shape in SEQ_SHAPES,
                 )
             )
 
@@ -227,6 +254,9 @@ def create_for_signature_model(
         expected_header_parameters = {p for p in param_definitions if p.param_type == ParamType.HEADER}
         expected_cookie_parameters = {p for p in param_definitions if p.param_type == ParamType.COOKIE}
         expected_query_parameters = {p for p in param_definitions if p.param_type == ParamType.QUERY}
+        sequence_query_parameter_names = {
+            p.field_alias for p in param_definitions if p.param_type == ParamType.QUERY and p.is_sequence
+        }
 
         expected_form_data = None
         data_model_field = signature_model.__fields__.get("data")
@@ -271,6 +301,7 @@ def create_for_signature_model(
             expected_cookie_params=expected_cookie_parameters,
             expected_header_params=expected_header_parameters,
             expected_reserved_kwargs=cast(Set[ReservedKwargs], expected_reserved_kwargs),
+            sequence_query_parameter_names=sequence_query_parameter_names,
         )
 
     @classmethod
@@ -338,11 +369,19 @@ def validate_raw_kwargs(
                 f"The following kwargs have been used: {', '.join(used_reserved_kwargs)}"
             )
 
+    def _sequence_or_scalar_param(self, key: str, value: List[str]) -> Union[str, List[str]]:
+        """
+        Returns the first element of 'value' if we expect it to be a scalar value (appears in self.sequence_query_parameter_names)
+        and it contains only a single element.
+        """
+        return value[0] if key not in self.sequence_query_parameter_names and len(value) == 1 else value
+
     def to_kwargs(self, connection: Union[WebSocket, Request]) -> Dict[str, Any]:
         """
         Return a dictionary of kwargs. Async values, i.e. CoRoutines, are not resolved to ensure this function is sync.
         """
         reserved_kwargs: Dict[str, Any] = {}
+        connection_query_params = {k: self._sequence_or_scalar_param(k, v) for k, v in connection.query_params.items()}
         if self.expected_reserved_kwargs:
             if "state" in self.expected_reserved_kwargs:
                 reserved_kwargs["state"] = connection.app.state.copy()
@@ -351,7 +390,7 @@ def to_kwargs(self, connection: Union[WebSocket, Request]) -> Dict[str, Any]:
             if "cookies" in self.expected_reserved_kwargs:
                 reserved_kwargs["cookies"] = connection.cookies
             if "query" in self.expected_reserved_kwargs:
-                reserved_kwargs["query"] = connection.query_params
+                reserved_kwargs["query"] = connection_query_params
             if "request" in self.expected_reserved_kwargs:
                 reserved_kwargs["request"] = connection
             if "socket" in self.expected_reserved_kwargs:
@@ -366,9 +405,9 @@ def to_kwargs(self, connection: Union[WebSocket, Request]) -> Dict[str, Any]:
                 for param in self.expected_path_params
             }
             query_params = {
-                param.field_name: connection.query_params[param.field_alias]
+                param.field_name: connection_query_params[param.field_alias]
                 if param.is_required
-                else connection.query_params.get(param.field_alias, param.default_value)
+                else connection_query_params.get(param.field_alias, param.default_value)
                 for param in self.expected_query_params
             }
             header_params = {
diff --git a/starlite/parsers.py b/starlite/parsers.py
--- a/starlite/parsers.py
+++ b/starlite/parsers.py
@@ -17,9 +17,7 @@
 _false_values = {"False", "false"}
 
 
-def _query_param_reducer(
-    acc: Dict[str, Union[str, List[str]]], cur: Tuple[str, str]
-) -> Dict[str, Union[str, List[str]]]:
+def _query_param_reducer(acc: Dict[str, List[str]], cur: Tuple[str, str]) -> Dict[str, List[str]]:
     """
     Reducer function - acc is a dictionary, cur is a tuple of key + value
 
@@ -32,11 +30,9 @@ def _query_param_reducer(
         value = False  # type: ignore
     param = acc.get(key)
     if param is None:
-        acc[key] = value
-    elif isinstance(param, str):
-        acc[key] = [param, value]
+        acc[key] = [value]
     else:
-        acc[key].append(value)  # type: ignore
+        acc[key].append(value)
     return acc
 
 


Test patch:
diff --git a/tests/kwargs/test_query_params.py b/tests/kwargs/test_query_params.py
--- a/tests/kwargs/test_query_params.py
+++ b/tests/kwargs/test_query_params.py
@@ -1,5 +1,16 @@
 from datetime import datetime
-from typing import List, Optional
+from typing import (
+    Any,
+    Deque,
+    Dict,
+    FrozenSet,
+    List,
+    MutableSequence,
+    Optional,
+    Set,
+    Tuple,
+    Union,
+)
 from urllib.parse import urlencode
 
 import pytest
@@ -78,6 +89,16 @@
             },
             False,
         ),
+        (
+            {
+                "page": 1,
+                "pageSize": 1,
+                "brands": ["Nike"],
+                "from_date": datetime.now().timestamp(),
+                "to_date": datetime.now().timestamp(),
+            },
+            False,
+        ),
     ],
 )
 def test_query_params(params_dict: dict, should_raise: bool) -> None:
@@ -103,3 +124,65 @@ def test_method(
             assert response.status_code == HTTP_400_BAD_REQUEST
         else:
             assert response.status_code == HTTP_200_OK
+
+
+@pytest.mark.parametrize(
+    "expected_type,provided_value,default,expected_response_code",
+    [
+        (List[int], [1, 2, 3], ..., HTTP_200_OK),
+        (List[int], [1], ..., HTTP_200_OK),
+        (List[str], ["foo", "bar"], Parameter(min_items=1), HTTP_200_OK),
+        (List[str], ["foo", "bar"], Parameter(min_items=3), HTTP_400_BAD_REQUEST),
+        (List[int], ["foo", "bar"], ..., HTTP_400_BAD_REQUEST),
+        (Tuple[int, str, int], (1, "foo", 2), ..., HTTP_200_OK),
+        (Optional[List[str]], [], None, HTTP_200_OK),
+        (Any, [1, 2, 3], ..., HTTP_200_OK),
+        (Union[int, List[int]], [1, 2, 3], None, HTTP_200_OK),
+        (Union[int, List[int]], [1], None, HTTP_200_OK),
+        (Deque[int], [1, 2, 3], None, HTTP_200_OK),
+        (Set[int], [1, 2, 3], None, HTTP_200_OK),
+        (FrozenSet[int], [1, 2, 3], None, HTTP_200_OK),
+        (Tuple[int, ...], [1, 2, 3], None, HTTP_200_OK),
+        (MutableSequence[int], [1, 2, 3], None, HTTP_200_OK),
+    ],
+)
+def test_query_param_arrays(expected_type: Any, provided_value: Any, default: Any, expected_response_code: int) -> None:
+    test_path = "/test"
+
+    @get(test_path)
+    def test_method_with_default(param: Any = default) -> None:
+        return None
+
+    @get(test_path)
+    def test_method_without_default(param: Any) -> None:
+        return None
+
+    test_method = test_method_without_default if default is ... else test_method_with_default
+    # Set the type annotation of 'param' in a way mypy can deal with
+    test_method.fn.__annotations__["param"] = expected_type
+
+    with create_test_client(test_method) as client:
+        params = urlencode({"param": provided_value}, doseq=True)
+        response = client.get(f"{test_path}?{params}")
+        assert response.status_code == expected_response_code
+
+
+def test_query_kwarg() -> None:
+    test_path = "/test"
+
+    params = urlencode(
+        {
+            "a": ["foo", "bar"],
+            "b": "qux",
+        },
+        doseq=True,
+    )
+
+    @get(test_path)
+    def test_method(a: List[str], b: List[str], query: Dict[str, Union[str, List[str]]]) -> None:
+        assert query == {"a": ["foo", "bar"], "b": ["qux"]}
+        return None
+
+    with create_test_client(test_method) as client:
+        response = client.get(f"{test_path}?{params}")
+        assert response.status_code == HTTP_200_OK
diff --git a/tests/test_parsers.py b/tests/test_parsers.py
--- a/tests/test_parsers.py
+++ b/tests/test_parsers.py
@@ -12,4 +12,10 @@ def test_parse_query_params() -> None:
     }
     request = create_test_request(query=query)  # type: ignore
     result = parse_query_params(connection=request)
-    assert result == query
+    assert result == {
+        "value": ["10"],
+        "veggies": ["tomato", "potato", "aubergine"],
+        "calories": ["122.53"],
+        "healthy": [True],
+        "polluting": [False],
+    }