Repository: vitalik/django-ninja

Issue:
Query parameters from Schema do not recognise lists with pydantic.Field alias
Using a schema to encapsulate GET requests with a `pydantic.Field` to handle `alias` will result in a 422 error.

```python
class Filters(Schema):
  slug__in: typing.List[str] = pydantic.Field(
      None,
      alias="slugs",
  )

@api.get("/filters/")
def test_filters(request, filters: Filters = Query(...)):
  return filters.dict()
```

Expected response to http://127.0.0.1:8000/api/filters/?slugs=a&slugs=b

```python
{
  "slug__in": [
    "a", 
    "b"
  ]
}
```

Actual response to http://127.0.0.1:8000/api/filters/?slugs=a&slugs=b

```python
{
  "detail": [
    {
      "loc": [
        "query",
        "filters",
        "slugs"
      ],
      "msg": "value is not a valid list",
      "type": "type_error.list"
    }
  ]
}
```

One work around is to *not* use aliases at all, but this is not ideal.

```python
class Filters(Schema):
  slugs: typing.List[str] = pydantic.Field(None)
```


Discussion:

Patch:
diff --git a/docs/src/tutorial/query/code010.py b/docs/src/tutorial/query/code010.py
index b60e90d..7150396 100644
--- a/docs/src/tutorial/query/code010.py
+++ b/docs/src/tutorial/query/code010.py
@@ -1,11 +1,16 @@
 import datetime
-from ninja import Schema, Query
+from typing import List
+
+from pydantic import Field
+
+from ninja import Query, Schema
 
 
 class Filters(Schema):
     limit: int = 100
     offset: int = None
     query: str = None
+    category__in: List[str] = Field(None, alias="categories")
 
 
 @api.get("/filter")
diff --git a/ninja/signature/details.py b/ninja/signature/details.py
index cca9b2a..488aa30 100644
--- a/ninja/signature/details.py
+++ b/ninja/signature/details.py
@@ -1,9 +1,20 @@
 import inspect
 from collections import defaultdict, namedtuple
-from typing import Any, Callable, Dict, List
+from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
+
+try:
+    from typing import get_origin  # type: ignore
+except ImportError:  # pragma: no coverage
+
+    def get_origin(tp: Any) -> Optional[Any]:
+        return getattr(tp, "__origin__", None)
+
 
 import pydantic
 
+if TYPE_CHECKING:
+    from pydantic.fields import ModelField  # pragma: no cover
+
 from ninja import params
 from ninja.signature.utils import get_path_param_names, get_typed_signature
 
@@ -117,10 +128,19 @@ def is_pydantic_model(cls: Any) -> bool:
 
 def is_collection_type(annotation: Any) -> bool:
     # List[int]  =>  __origin__ = list, __args__ = int
-    origin = getattr(annotation, "__origin__", None)
+    origin = get_origin(annotation)
     return origin in (List, list, set, tuple)  # TODO: I gues we should handle only list
 
 
+def detect_pydantic_model_collection_fields(model: pydantic.BaseModel) -> List[str]:
+    def _list_field_name(field: "ModelField") -> Optional[str]:
+        if get_origin(field.outer_type_) in (List, list, tuple, set):
+            return str(field.alias)
+        return None
+
+    return list(filter(None, map(_list_field_name, model.__fields__.values())))
+
+
 def detect_collection_fields(args: List[FuncParam]) -> List[str]:
     """
     QueryDict has values that are always lists, so we need to help django ninja to understand
@@ -130,11 +150,6 @@ def detect_collection_fields(args: List[FuncParam]) -> List[str]:
     result = [i.name for i in args if i.is_collection]
 
     if len(args) == 1 and is_pydantic_model(args[0].annotation):
-        # There is a special case - when query param of form param is only one and it's defined as pydantic model
-        # In that case we need to detect collection
-        # see #34 for more details about the issue
-        for name, annotation in args[0].annotation.__annotations__.items():
-            if is_collection_type(annotation):
-                result.append(name)
+        result += detect_pydantic_model_collection_fields(args[0].annotation)
 
     return result


Test patch:
diff --git a/tests/test_docs/test_query.py b/tests/test_docs/test_query.py
index a7d055d..720ea95 100644
--- a/tests/test_docs/test_query.py
+++ b/tests/test_docs/test_query.py
@@ -63,16 +63,39 @@ def test_examples():
         # Schema
 
         assert client.get("/filter").json() == {
-            "filters": {"limit": 100, "offset": None, "query": None}
+            "filters": {
+                "limit": 100,
+                "offset": None,
+                "query": None,
+                "category__in": None,
+            }
         }
         assert client.get("/filter?limit=10").json() == {
-            "filters": {"limit": 10, "offset": None, "query": None}
+            "filters": {
+                "limit": 10,
+                "offset": None,
+                "query": None,
+                "category__in": None,
+            }
         }
         assert client.get("/filter?offset=10").json() == {
-            "filters": {"limit": 100, "offset": 10, "query": None}
+            "filters": {"limit": 100, "offset": 10, "query": None, "category__in": None}
         }
         assert client.get("/filter?query=10").json() == {
-            "filters": {"limit": 100, "offset": None, "query": "10"}
+            "filters": {
+                "limit": 100,
+                "offset": None,
+                "query": "10",
+                "category__in": None,
+            }
+        }
+        assert client.get("/filter?categories=a&categories=b").json() == {
+            "filters": {
+                "limit": 100,
+                "offset": None,
+                "query": None,
+                "category__in": ["a", "b"],
+            }
         }
 
         schema = api.get_openapi_schema("")
@@ -96,4 +119,14 @@ def test_examples():
                 "required": False,
                 "schema": {"title": "Query", "type": "string"},
             },
+            {
+                "in": "query",
+                "name": "categories",
+                "required": False,
+                "schema": {
+                    "title": "Categories",
+                    "type": "array",
+                    "items": {"type": "string"},
+                },
+            },
         ]
diff --git a/tests/test_lists.py b/tests/test_lists.py
index 71338cb..66b0ba9 100644
--- a/tests/test_lists.py
+++ b/tests/test_lists.py
@@ -1,7 +1,7 @@
 import pytest
 from typing import List
 from ninja import Router, Query, Form, Schema
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
 from client import NinjaClient
 
 
@@ -12,7 +12,9 @@ router = Router()
 
 @router.post("/list1")
 def listview1(
-    request, query: List[int] = Query(...), form: List[int] = Form(...),
+    request,
+    query: List[int] = Query(...),
+    form: List[int] = Form(...),
 ):
     return {
         "query": query,
@@ -22,7 +24,9 @@ def listview1(
 
 @router.post("/list2")
 def listview2(
-    request, body: List[int], query: List[int] = Query(...),
+    request,
+    body: List[int],
+    query: List[int] = Query(...),
 ):
     return {
         "query": query,
@@ -52,11 +56,13 @@ def listviewdefault(request, body: List[int] = [1]):
 
 class Filters(Schema):
     tags: List[str] = []
+    other_tags: List[str] = Field([], alias="other_tags_alias")
 
 
 @router.post("/list4")
 def listview4(
-    request, filters: Filters = Query(...),
+    request,
+    filters: Filters = Query(...),
 ):
     return {
         "filters": filters,
@@ -96,19 +102,19 @@ client = NinjaClient(router)
             {"body": [1, 2]},
         ),
         (
-            "/list4?tags=a&tags=b",
+            "/list4?tags=a&tags=b&other_tags_alias=a&other_tags_alias=b",
             {},
-            {"filters": {"tags": ["a", "b"]}},
+            {"filters": {"tags": ["a", "b"], "other_tags": ["a", "b"]}},
         ),
         (
-            "/list4?tags=abc",
+            "/list4?tags=abc&other_tags_alias=abc",
             {},
-            {"filters": {"tags": ["abc"]}},
+            {"filters": {"tags": ["abc"], "other_tags": ["abc"]}},
         ),
         (
             "/list4",
             {},
-            {"filters": {"tags": []}},
+            {"filters": {"tags": [], "other_tags": []}},
         ),
     ]
     # fmt: on