Repository: litestar-org/litestar

Issue:
Enhancement: Add 'path' type to path parameters
Starlette's route path parameters include a `path` type that matches any further path components. Example: `Route('/uploaded/{rest_of_path:path}', uploaded)` would match `/uploaded/some-dir/some-file.txt` with `rest_of_path` set to `some-dir/some-file.txt`.

A couple cases where this might be useful:

- A file upload service where uploaded files are exposed as a virtual directory tree
- A blog where posts have extra path components for readability and SEO which are otherwise ignored, like `/posts/1234/2022/8/14/how-to-use-path-parameters/` with only `/posts/1234` being necessary

It doesn't look like this is supported yet in Starlite. Is there a recommended approach in the meantime?


Discussion:
Not at present. I'm not sure how simple this is with our routing system. 
I looked through the routing code and it should be relatively simple to add this. However, I did discover a couple bugs I will create a separate issue for.
Great, if you are confident, a PR is welcome 😉 
@waweber do you intend to add this?

Resolving patch:
diff --git a/starlite/app.py b/starlite/app.py
--- a/starlite/app.py
+++ b/starlite/app.py
@@ -1,3 +1,4 @@
+from pathlib import Path
 from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union, cast
 
 from pydantic import validate_arguments
@@ -7,7 +8,12 @@
 from starlette.middleware.trustedhost import TrustedHostMiddleware
 from typing_extensions import TypedDict
 
-from starlite.asgi import PathParamPlaceholder, RouteMapNode, StarliteASGIRouter
+from starlite.asgi import (
+    PathParameterTypePathDesignator,
+    PathParamNode,
+    RouteMapNode,
+    StarliteASGIRouter,
+)
 from starlite.config import (
     CacheConfig,
     CompressionConfig,
@@ -277,6 +283,7 @@ def register(self, value: ControllerRouterHandler) -> None:  # type: ignore[over
             else:
                 route_handlers = [cast("Union[WebSocketRoute, ASGIRoute]", route).route_handler]  # type: ignore
             for route_handler in route_handlers:
+
                 self._create_handler_signature_model(route_handler=route_handler)
                 route_handler.resolve_guards()
                 route_handler.resolve_middleware()
@@ -360,8 +367,11 @@ def _add_node_to_route_map(self, route: BaseRoute) -> RouteMapNode:
                 components_set = cast("ComponentsSet", current_node["_components"])
 
                 if isinstance(component, dict):
+                    # The rest of the path should be regarded as a parameter value.
+                    if component["type"] is Path:
+                        components_set.add(PathParameterTypePathDesignator)
                     # Represent path parameters using a special value
-                    component = PathParamPlaceholder
+                    component = PathParamNode
 
                 components_set.add(component)
 
diff --git a/starlite/asgi.py b/starlite/asgi.py
--- a/starlite/asgi.py
+++ b/starlite/asgi.py
@@ -1,6 +1,28 @@
+import re
+from datetime import date, datetime, time, timedelta
+from decimal import Decimal
 from inspect import getfullargspec, isawaitable, ismethod
-from typing import TYPE_CHECKING, Any, Dict, List, Set, Tuple, Type, Union, cast
+from pathlib import Path
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Callable,
+    Dict,
+    List,
+    Set,
+    Tuple,
+    Type,
+    Union,
+    cast,
+)
+from uuid import UUID
 
+from pydantic.datetime_parse import (
+    parse_date,
+    parse_datetime,
+    parse_duration,
+    parse_time,
+)
 from starlette.routing import Router as StarletteRouter
 
 from starlite.enums import ScopeType
@@ -18,13 +40,18 @@
     from starlite.types import LifeCycleHandler
 
 
-class PathParamPlaceholder:
+class PathParamNode:
     """Sentinel object to represent a path param in the route map."""
 
 
-PathParamPlaceholderType = Type[PathParamPlaceholder]
+class PathParameterTypePathDesignator:
+    """Sentinel object to a path parameter of type 'path'."""
+
+
+PathParamPlaceholderType = Type[PathParamNode]
+TerminusNodePlaceholderType = Type[PathParameterTypePathDesignator]
 RouteMapNode = Dict[Union[str, PathParamPlaceholderType], Any]
-ComponentsSet = Set[Union[str, PathParamPlaceholderType]]
+ComponentsSet = Set[Union[str, PathParamPlaceholderType, TerminusNodePlaceholderType]]
 
 
 class StarliteASGIRouter(StarletteRouter):
@@ -44,12 +71,20 @@ def _traverse_route_map(self, path: str, scope: "Scope") -> Tuple[RouteMapNode,
         """Traverses the application route mapping and retrieves the correct
         node for the request url.
 
-        Raises NotFoundException if no correlating node is found
+        Args:
+            path: The request's path.
+            scope: The ASGI connection scope.
+
+        Raises:
+             NotFoundException: if no correlating node is found.
+
+        Returns:
+            A tuple containing the target RouteMapNode and a list containing all path parameter values.
         """
         path_params: List[str] = []
         current_node = self.app.route_map
         components = ["/", *[component for component in path.split("/") if component]]
-        for component in components:
+        for idx, component in enumerate(components):
             components_set = cast("ComponentsSet", current_node["_components"])
             if component in components_set:
                 current_node = cast("RouteMapNode", current_node[component])
@@ -57,9 +92,12 @@ def _traverse_route_map(self, path: str, scope: "Scope") -> Tuple[RouteMapNode,
                     self._handle_static_path(scope=scope, node=current_node)
                     break
                 continue
-            if PathParamPlaceholder in components_set:
+            if PathParamNode in components_set:
+                current_node = cast("RouteMapNode", current_node[PathParamNode])
+                if PathParameterTypePathDesignator in components_set:
+                    path_params.append("/".join(path.split("/")[idx:]))
+                    break
                 path_params.append(component)
-                current_node = cast("RouteMapNode", current_node[PathParamPlaceholder])
                 continue
             raise NotFoundException()
         return current_node, path_params
@@ -70,7 +108,7 @@ def _handle_static_path(scope: "Scope", node: RouteMapNode) -> None:
         work as expected.
 
         Args:
-            scope: Request Scope
+            scope: The ASGI connection scope.
             node: Trie Node
 
         Returns:
@@ -92,19 +130,32 @@ def _parse_path_parameters(
             request_path_parameter_values: A list of raw strings sent as path parameters as part of the request
 
         Raises:
-            ValidationException
+            ValidationException: if path parameter parsing fails
 
         Returns:
             A dictionary mapping path parameter names to parsed values
         """
         result: Dict[str, Any] = {}
+        parsers_map: Dict[Any, Callable] = {
+            str: str,
+            float: float,
+            int: int,
+            Decimal: Decimal,
+            UUID: UUID,
+            Path: lambda x: Path(re.sub("//+", "", (x.lstrip("/")))),
+            date: parse_date,
+            datetime: parse_datetime,
+            time: parse_time,
+            timedelta: parse_duration,
+        }
 
         try:
             for idx, parameter_definition in enumerate(path_parameter_definitions):
                 raw_param_value = request_path_parameter_values[idx]
                 parameter_type = parameter_definition["type"]
                 parameter_name = parameter_definition["name"]
-                result[parameter_name] = parameter_type(raw_param_value)
+                parser = parsers_map[parameter_type]
+                result[parameter_name] = parser(raw_param_value)
             return result
         except (ValueError, TypeError, KeyError) as e:  # pragma: no cover
             raise ValidationException(
diff --git a/starlite/routes/base.py b/starlite/routes/base.py
--- a/starlite/routes/base.py
+++ b/starlite/routes/base.py
@@ -1,5 +1,8 @@
 import re
 from abc import ABC, abstractmethod
+from datetime import date, datetime, time, timedelta
+from decimal import Decimal
+from pathlib import Path
 from typing import TYPE_CHECKING, List, Optional, Tuple, Type, Union
 from uuid import UUID
 
@@ -19,7 +22,18 @@
 
 
 param_match_regex = re.compile(r"{(.*?)}")
-param_type_map = {"str": str, "int": int, "float": float, "uuid": UUID}
+param_type_map = {
+    "str": str,
+    "int": int,
+    "float": float,
+    "uuid": UUID,
+    "decimal": Decimal,
+    "date": date,
+    "datetime": datetime,
+    "time": time,
+    "timedelta": timedelta,
+    "path": Path,
+}
 
 
 class PathParameterDefinition(TypedDict):
@@ -123,7 +137,7 @@ def _validate_path_parameter(param: str) -> None:
             raise ImproperlyConfiguredException("Path parameter names should be of length greater than zero")
         if param_type not in param_type_map:
             raise ImproperlyConfiguredException(
-                "Path parameters should be declared with an allowed type, i.e. 'str', 'int', 'float' or 'uuid'"
+                f"Path parameters should be declared with an allowed type, i.e. one of {','.join(param_type_map.keys())}"
             )
 
     @classmethod


Test patch:
diff --git a/tests/kwargs/test_path_params.py b/tests/kwargs/test_path_params.py
--- a/tests/kwargs/test_path_params.py
+++ b/tests/kwargs/test_path_params.py
@@ -1,3 +1,7 @@
+from datetime import date, datetime, timedelta
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
 from uuid import uuid1, uuid4
 
 import pytest
@@ -100,7 +104,7 @@ def test_method(
 def test_path_param_validation(path: str) -> None:
     @get(path=path)
     def test_method() -> None:
-        pass
+        raise AssertionError("should not be called")
 
     with pytest.raises(ImproperlyConfiguredException):
         Starlite(route_handlers=[test_method])
@@ -108,8 +112,41 @@ def test_method() -> None:
 
 def test_duplicate_path_param_validation() -> None:
     @get(path="/{param:int}/foo/{param:int}")
-    def test_method(param: int) -> None:
-        pass
+    def test_method() -> None:
+        raise AssertionError("should not be called")
 
     with pytest.raises(ImproperlyConfiguredException):
         Starlite(route_handlers=[test_method])
+
+
+@pytest.mark.parametrize(
+    "param_type_name, param_type_class, value",
+    [
+        # ["str", str, "abc"],
+        # ["int", int, 1],
+        # ["float", float, 1.01],
+        # ["uuid", UUID, uuid4()],
+        # ["decimal", Decimal, Decimal("1.00001")],
+        # ["date", date, date.today().isoformat()],
+        # ["datetime", datetime, datetime.now().isoformat()],
+        # ["timedelta", timedelta, timedelta(days=1).total_seconds()],
+        ["path", Path, "/1/2/3/4/some-file.txt"],
+    ],
+)
+def test_path_param_type_resolution(param_type_name: str, param_type_class: Any, value: Any) -> None:
+    @get("/some/test/path/{test:" + param_type_name + "}")
+    def handler(test: param_type_class) -> None:  # type: ignore
+        if isinstance(test, (date, datetime)):
+            assert test.isoformat() == value  # type: ignore
+        elif isinstance(test, timedelta):  # type: ignore
+            assert test.total_seconds() == value
+        elif isinstance(test, Decimal):
+            assert str(test) == str(value)
+        elif isinstance(test, Path):
+            assert str(test) == "1/2/3/4/some-file.txt"
+        else:
+            assert test == value
+
+    with create_test_client(handler) as client:
+        response = client.get("/some/test/path/" + str(value))
+        assert response.status_code == HTTP_200_OK
diff --git a/tests/routing/test_route_map.py b/tests/routing/test_route_map.py
--- a/tests/routing/test_route_map.py
+++ b/tests/routing/test_route_map.py
@@ -8,7 +8,7 @@
 from hypothesis.strategies import DrawFn
 
 from starlite import HTTPRoute, get
-from starlite.asgi import PathParamPlaceholder, PathParamPlaceholderType, RouteMapNode
+from starlite.asgi import PathParamNode, PathParamPlaceholderType, RouteMapNode
 from starlite.middleware.exceptions import ExceptionHandlerMiddleware
 from starlite.testing import create_test_client
 
@@ -25,7 +25,7 @@ def is_path_in_route_map(route_map: RouteMapNode, path: str, path_params: Set[st
         [
             "/",
             *[
-                PathParamPlaceholder if param_pattern.fullmatch(component) else component
+                PathParamNode if param_pattern.fullmatch(component) else component
                 for component in path.split("/")
                 if component
             ],