Repository: vitalik/django-ninja

Issue:
Path param with converter(s)
Need to support Djaggo path converters
https://docs.djangoproject.com/en/3.1/topics/http/urls/#path-converters

basically this should work:
```Python
@api.get('/test-path/{path:page}')
def test_path(request, page):
    return page
```


Discussion:

Patch:
diff --git a/ninja/main.py b/ninja/main.py
index e4fbf7d..3ccf3c6 100644
--- a/ninja/main.py
+++ b/ninja/main.py
@@ -313,12 +313,13 @@ class NinjaAPI:
     def urls(self) -> Tuple[Any, ...]:
         self._validate()
         return (
-            self._get_urls(),
+            self._get_urls,
             "ninja",
             self.urls_namespace.split(":")[-1],
             # ^ if api included into nested urls, we only care about last bit here
         )
 
+    @property
     def _get_urls(self) -> List[URLPattern]:
         result = get_openapi_urls(self)
 
diff --git a/ninja/signature/details.py b/ninja/signature/details.py
index a680b72..632d187 100644
--- a/ninja/signature/details.py
+++ b/ninja/signature/details.py
@@ -129,17 +129,19 @@ class ViewSignature:
 
         # 2) if param name is a part of the path parameter
         elif name in self.path_params_names:
-            assert arg.default == self.signature.empty, f"'{name}' is a path param"
+            assert (
+                arg.default == self.signature.empty
+            ), f"'{name}' is a path param, default not allowed"
             param_source = params.Path(...)
 
-        # 3) if param have no  type annotation or annotation is not part of pydantic model:
+        # 3) if param is a collection or annotation is part of pydantic model:
         elif is_collection or is_pydantic_model(annotation):
             if arg.default == self.signature.empty:
                 param_source = params.Body(...)
             else:
                 param_source = params.Body(arg.default)
 
-        # 4) the last case is body param
+        # 4) the last case is query param
         else:
             if arg.default == self.signature.empty:
                 param_source = params.Query(...)
@@ -158,7 +160,12 @@ def is_pydantic_model(cls: Any) -> bool:
 
 def is_collection_type(annotation: Any) -> bool:
     origin = get_collection_origin(annotation)
-    return origin in (List, list, set, tuple)  # TODO: I gues we should handle only list
+    return origin in (
+        List,
+        list,
+        set,
+        tuple,
+    )  # TODO: I guess we should handle only list
 
 
 def detect_pydantic_model_collection_fields(model: pydantic.BaseModel) -> List[str]:
diff --git a/ninja/signature/utils.py b/ninja/signature/utils.py
index 50e6827..fe46432 100644
--- a/ninja/signature/utils.py
+++ b/ninja/signature/utils.py
@@ -3,6 +3,8 @@ import inspect
 import re
 from typing import Any, Callable, Set
 
+from django.urls import register_converter
+from django.urls.converters import UUIDConverter
 from pydantic.typing import ForwardRef, evaluate_forwardref
 
 from ninja.types import DictStrAny
@@ -47,8 +49,8 @@ def make_forwardref(annotation: str, globalns: DictStrAny) -> Any:
 
 
 def get_path_param_names(path: str) -> Set[str]:
-    "turns path string like /foo/{var}/path/{another}/end to set ['var', 'another']"
-    return {item.strip("{}") for item in re.findall("{[^}]*}", path)}
+    """turns path string like /foo/{var}/path/{int:another}/end to set {'var', 'another'}"""
+    return {item.strip("{}").split(":")[-1] for item in re.findall("{[^}]*}", path)}
 
 
 def is_async(callable: Callable) -> bool:
@@ -62,3 +64,18 @@ def has_kwargs(call: Callable) -> bool:
         if param.kind == param.VAR_KEYWORD:
             return True
     return False
+
+
+class NinjaUUIDConverter:
+    """Return a path converted UUID as a str instead of the standard UUID"""
+
+    regex = UUIDConverter.regex
+
+    def to_python(self, value: str) -> str:
+        return value
+
+    def to_url(self, value: Any) -> str:
+        return str(value)
+
+
+register_converter(NinjaUUIDConverter, "uuid")


Test patch:
diff --git a/tests/main.py b/tests/main.py
index ab26e62..b2946df 100644
--- a/tests/main.py
+++ b/tests/main.py
@@ -1,3 +1,4 @@
+from uuid import UUID
 from ninja import Router, Query, Path
 
 
@@ -131,6 +132,51 @@ def get_path_param_le_ge_int(request, item_id: int = Path(..., le=3, ge=1)):
     return item_id
 
 
+@router.get("/path/param-django-str/{str:item_id}")
+def get_path_param_django_str(request, item_id):
+    return item_id
+
+
+@router.get("/path/param-django-int/{int:item_id}")
+def get_path_param_django_int(request, item_id:int):
+    assert isinstance(item_id, int)
+    return item_id
+
+
+@router.get("/path/param-django-int/not-an-int")
+def get_path_param_django_not_an_int(request):
+    """Verify that url resolution for get_path_param_django_int passes non-ints forward"""
+    return f"Found not-an-int"
+
+
+@router.get("/path/param-django-int-str/{int:item_id}")
+def get_path_param_django_int(request, item_id:str):
+    assert isinstance(item_id, str)
+    return item_id
+
+
+@router.get("/path/param-django-slug/{slug:item_id}")
+def get_path_param_django_slug(request, item_id):
+    return item_id
+
+
+@router.get("/path/param-django-uuid/{uuid:item_id}")
+def get_path_param_django_uuid(request, item_id: UUID):
+    assert isinstance(item_id, UUID)
+    return item_id
+
+
+@router.get("/path/param-django-uuid-str/{uuid:item_id}")
+def get_path_param_django_int(request, item_id):
+    assert isinstance(item_id, str)
+    return item_id
+
+
+@router.get("/path/param-django-path/{path:item_id}/after")
+def get_path_param_django_int(request, item_id):
+    return item_id
+
+
 @router.get("/query")
 def get_query(request, query):
     return f"foo bar {query}"
@@ -175,3 +221,40 @@ def get_query_param_required(request, query=Query(...)):
 @router.get("/query/param-required/int")
 def get_query_param_required_type(request, query: int = Query(...)):
     return f"foo bar {query}"
+
+
+class CustomPathConverter1:
+    regex = '[0-9]+'
+
+    def to_python(self, value) -> 'int':
+        """reverse the string and convert to int"""
+        return int(value[::-1])
+
+    def to_url(self, value):
+        return str(value)
+
+
+class CustomPathConverter2:
+    regex = "[0-9]+"
+
+    def to_python(self, value):
+        """reverse the string and convert to float like"""
+        return f"0.{value[::-1]}"
+
+    def to_url(self, value):
+        return str(value)
+
+
+from django.urls import register_converter
+register_converter(CustomPathConverter1, 'custom-int')
+register_converter(CustomPathConverter2, 'custom-float')
+
+
+@router.get("/path/param-django-custom-int/{custom-int:item_id}")
+def get_path_param_django_int(request, item_id: int):
+    return item_id
+
+
+@router.get("/path/param-django-custom-float/{custom-float:item_id}")
+def get_path_param_django_float(request, item_id:float):
+    return item_id
diff --git a/tests/test_path.py b/tests/test_path.py
index 4831587..0c44083 100644
--- a/tests/test_path.py
+++ b/tests/test_path.py
@@ -1,5 +1,6 @@
 import pytest
 from main import router
+from ninja import Router
 from ninja.testing import TestClient
 
 
@@ -245,3 +246,69 @@ def test_get_path(path, expected_status, expected_response):
     response = client.get(path)
     assert response.status_code == expected_status
     assert response.json() == expected_response
+
+
+@pytest.mark.parametrize(
+    "path,expected_status,expected_response",
+    [
+        ("/path/param-django-str/42", 200, "42"),
+        ("/path/param-django-str/-1", 200, "-1"),
+        ("/path/param-django-str/foobar", 200, "foobar"),
+        ("/path/param-django-int/0", 200, 0),
+        ("/path/param-django-int/42", 200, 42),
+        ("/path/param-django-int/42.5", "Cannot resolve", Exception),
+        ("/path/param-django-int/-1", "Cannot resolve", Exception),
+        ("/path/param-django-int/True", "Cannot resolve", Exception),
+        ("/path/param-django-int/foobar", "Cannot resolve", Exception),
+        ("/path/param-django-int/not-an-int", 200, "Found not-an-int"),
+        ("/path/param-django-int-str/42", 200, '42'),
+        ("/path/param-django-int-str/42.5", "Cannot resolve", Exception),
+        (
+            "/path/param-django-slug/django-ninja-is-the-best",
+            200,
+            "django-ninja-is-the-best",
+        ),
+        ("/path/param-django-slug/42.5", "Cannot resolve", Exception),
+        (
+            "/path/param-django-uuid/31ea378c-c052-4b4c-bf0b-679ce5cfcc2a",
+            200,
+            "31ea378c-c052-4b4c-bf0b-679ce5cfcc2a",
+        ),
+        (
+            "/path/param-django-uuid/31ea378c-c052-4b4c-bf0b-679ce5cfcc2",
+            "Cannot resolve",
+            Exception,
+        ),
+        (
+            "/path/param-django-uuid-str/31ea378c-c052-4b4c-bf0b-679ce5cfcc2a",
+            200,
+            "31ea378c-c052-4b4c-bf0b-679ce5cfcc2a",
+        ),
+        ("/path/param-django-path/some/path/things/after", 200, "some/path/things"),
+        ("/path/param-django-path/less/path/after", 200, "less/path"),
+        ("/path/param-django-path/plugh/after", 200, "plugh"),
+        ("/path/param-django-path//after", "Cannot resolve", Exception),
+        ("/path/param-django-custom-int/42", 200, 24),
+        ("/path/param-django-custom-int/x42", "Cannot resolve", Exception),
+        ("/path/param-django-custom-float/42", 200, 0.24),
+        ("/path/param-django-custom-float/x42", "Cannot resolve", Exception),
+    ],
+)
+def test_get_path_django(path, expected_status, expected_response):
+    if expected_response == Exception:
+        with pytest.raises(Exception, match=expected_status):
+            client.get(path)
+    else:
+        response = client.get(path)
+        assert response.status_code == expected_status
+        assert response.json() == expected_response
+
+
+def test_path_signature_asserts():
+    test_router = Router()
+
+    match = "'item_id' is a path param, default not allowed"
+    with pytest.raises(AssertionError, match=match):
+        @test_router.get("/path/{item_id}")
+        def get_path_item_id(request, item_id='1'):
+            pass