Repository: encode/starlette

Issue:
Add support for route converters
Refs #182. Here's what I think route converters could look like in Starlette. If nothing else, it can be used as a base to work against.

Supports:
* `{param:int}`
* `{param:float}`
* `{param:path}`

The `convert_params` interface allows new converters to be added easily.

Example usage:
```python
# Integer
@app.route("/get/{number:int}")
async def func(request):
    number = request.path_params["number"]
    return JSONResponse({"num": number})

response = client.get('/get/25')
number = response.json()["num"]
type(number) == int  # True

# Float
@app.route("/get/{number:float}")
async def func(request):
    number = request.path_params["number"]
    return JSONResponse({"num": number})

response = client.get('/get/25.5')
number = response.json()["num"]
type(number) == float  # True

# Path
@app.route("/get/{param:path}")
async def func(request):
    return PlainTextResponse("Hello, world!")

response = client.get('/get/hello/world/1')
response.text == "Hello, world!"  # True
response.status_code == 200  # True
```


Discussion:

Resolving patch:
diff --git a/starlette/__init__.py b/starlette/__init__.py
--- a/starlette/__init__.py
+++ b/starlette/__init__.py
@@ -1 +1 @@
-__version__ = "0.8.3"
+__version__ = "0.8.4"
diff --git a/starlette/convertors.py b/starlette/convertors.py
new file mode 100644
--- /dev/null
+++ b/starlette/convertors.py
@@ -0,0 +1,69 @@
+import math
+import typing
+
+
+class Convertor:
+    regex = ""
+
+    def convert(self, value: str) -> typing.Any:
+        raise NotImplementedError()  # pragma: no cover
+
+    def to_string(self, value: typing.Any) -> str:
+        raise NotImplementedError()  # pragma: no cover
+
+
+class StringConvertor(Convertor):
+    regex = "[^/]+"
+
+    def convert(self, value: str) -> typing.Any:
+        return value
+
+    def to_string(self, value: typing.Any) -> str:
+        value = str(value)
+        assert "/" not in value, "May not contain path seperators"
+        assert value, "Must not be empty"
+        return value
+
+
+class PathConvertor(Convertor):
+    regex = ".*"
+
+    def convert(self, value: str) -> typing.Any:
+        return str(value)
+
+    def to_string(self, value: typing.Any) -> str:
+        return str(value)
+
+
+class IntegerConvertor(Convertor):
+    regex = "[0-9]+"
+
+    def convert(self, value: str) -> typing.Any:
+        return int(value)
+
+    def to_string(self, value: typing.Any) -> str:
+        value = int(value)
+        assert value >= 0, "Negative integers are not supported"
+        return str(value)
+
+
+class FloatConvertor(Convertor):
+    regex = "[0-9]+(.[0-9]+)?"
+
+    def convert(self, value: str) -> typing.Any:
+        return float(value)
+
+    def to_string(self, value: typing.Any) -> str:
+        value = float(value)
+        assert value >= 0.0, "Negative floats are not supported"
+        assert not math.isnan(value), "NaN values are not supported"
+        assert not math.isinf(value), "Infinite values are not supported"
+        return ("%0.20f" % value).rstrip("0").rstrip(".")
+
+
+CONVERTOR_TYPES = {
+    "str": StringConvertor(),
+    "path": PathConvertor(),
+    "int": IntegerConvertor(),
+    "float": FloatConvertor(),
+}
diff --git a/starlette/routing.py b/starlette/routing.py
--- a/starlette/routing.py
+++ b/starlette/routing.py
@@ -6,6 +6,7 @@
 from enum import Enum
 
 from starlette.concurrency import run_in_threadpool
+from starlette.convertors import CONVERTOR_TYPES, Convertor
 from starlette.datastructures import URL, URLPath
 from starlette.exceptions import HTTPException
 from starlette.requests import Request
@@ -73,15 +74,22 @@ def get_name(endpoint: typing.Callable) -> str:
 
 
 def replace_params(
-    path: str, path_params: typing.Dict[str, str]
+    path: str,
+    param_convertors: typing.Dict[str, Convertor],
+    path_params: typing.Dict[str, str],
 ) -> typing.Tuple[str, dict]:
     for key, value in list(path_params.items()):
         if "{" + key + "}" in path:
-            path_params.pop(key)
+            convertor = param_convertors[key]
+            value = convertor.to_string(value)
             path = path.replace("{" + key + "}", value)
+            path_params.pop(key)
     return path, path_params
 
 
+PARAM_REGEX = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}")
+
+
 class BaseRoute:
     def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]:
         raise NotImplementedError()  # pragma: no cover
@@ -92,6 +100,45 @@ def url_path_for(self, name: str, **path_params: str) -> URLPath:
     def __call__(self, scope: Scope) -> ASGIInstance:
         raise NotImplementedError()  # pragma: no cover
 
+    def compile_path(
+        self, path: str
+    ) -> typing.Tuple[typing.Pattern, str, typing.Dict[str, Convertor]]:
+        """
+        Given a path string, like: "/{username:str}", return a three-tuple
+        of (regex, format, {param_name:convertor}).
+
+        regex:      "/(?P<username>[^/]+)"
+        format:     "/{username}"
+        convertors: {"username": StringConvertor()}
+        """
+        path_regex = "^"
+        path_format = ""
+
+        idx = 0
+        param_convertors = {}
+        for match in PARAM_REGEX.finditer(path):
+            param_name, convertor_type = match.groups("str")
+            convertor_type = convertor_type.lstrip(":")
+            assert convertor_type in CONVERTOR_TYPES, (
+                "Unknown path convertor '%s'" % convertor_type
+            )
+            convertor = CONVERTOR_TYPES[convertor_type]
+
+            path_regex += path[idx : match.start()]
+            path_regex += "(?P<%s>%s)" % (param_name, convertor.regex)
+
+            path_format += path[idx : match.start()]
+            path_format += "{%s}" % param_name
+
+            param_convertors[param_name] = convertor
+
+            idx = match.end()
+
+        path_regex += path[idx:] + "$"
+        path_format += path[idx:]
+
+        return re.compile(path_regex), path_format, param_convertors
+
 
 class Route(BaseRoute):
     def __init__(
@@ -119,17 +166,19 @@ def __init__(
             self.app = endpoint
 
         self.methods = methods
-        regex = "^" + path + "$"
-        regex = re.sub("{([a-zA-Z_][a-zA-Z0-9_]*)}", r"(?P<\1>[^/]+)", regex)
-        self.path_regex = re.compile(regex)
-        self.param_names = set(self.path_regex.groupindex.keys())
+        self.path_regex, self.path_format, self.param_convertors = self.compile_path(
+            path
+        )
 
     def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]:
         if scope["type"] == "http":
             match = self.path_regex.match(scope["path"])
             if match:
+                matched_params = match.groupdict()
+                for key, value in matched_params.items():
+                    matched_params[key] = self.param_convertors[key].convert(value)
                 path_params = dict(scope.get("path_params", {}))
-                path_params.update(match.groupdict())
+                path_params.update(matched_params)
                 child_scope = {"endpoint": self.endpoint, "path_params": path_params}
                 if self.methods and scope["method"] not in self.methods:
                     return Match.PARTIAL, child_scope
@@ -138,9 +187,15 @@ def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]:
         return Match.NONE, {}
 
     def url_path_for(self, name: str, **path_params: str) -> URLPath:
-        if name != self.name or self.param_names != set(path_params.keys()):
+        seen_params = set(path_params.keys())
+        expected_params = set(self.param_convertors.keys())
+
+        if name != self.name or seen_params != expected_params:
             raise NoMatchFound()
-        path, remaining_params = replace_params(self.path, path_params)
+
+        path, remaining_params = replace_params(
+            self.path_format, self.param_convertors, path_params
+        )
         assert not remaining_params
         return URLPath(path=path, protocol="http")
 
@@ -178,23 +233,33 @@ def __init__(
 
         regex = "^" + path + "$"
         regex = re.sub("{([a-zA-Z_][a-zA-Z0-9_]*)}", r"(?P<\1>[^/]+)", regex)
-        self.path_regex = re.compile(regex)
-        self.param_names = set(self.path_regex.groupindex.keys())
+        self.path_regex, self.path_format, self.param_convertors = self.compile_path(
+            path
+        )
 
     def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]:
         if scope["type"] == "websocket":
             match = self.path_regex.match(scope["path"])
             if match:
+                matched_params = match.groupdict()
+                for key, value in matched_params.items():
+                    matched_params[key] = self.param_convertors[key].convert(value)
                 path_params = dict(scope.get("path_params", {}))
-                path_params.update(match.groupdict())
+                path_params.update(matched_params)
                 child_scope = {"endpoint": self.endpoint, "path_params": path_params}
                 return Match.FULL, child_scope
         return Match.NONE, {}
 
     def url_path_for(self, name: str, **path_params: str) -> URLPath:
-        if name != self.name or self.param_names != set(path_params.keys()):
+        seen_params = set(path_params.keys())
+        expected_params = set(self.param_convertors.keys())
+
+        if name != self.name or seen_params != expected_params:
             raise NoMatchFound()
-        path, remaining_params = replace_params(self.path, path_params)
+
+        path, remaining_params = replace_params(
+            self.path_format, self.param_convertors, path_params
+        )
         assert not remaining_params
         return URLPath(path=path, protocol="websocket")
 
@@ -214,10 +279,10 @@ def __init__(self, path: str, app: ASGIApp, name: str = None) -> None:
         assert path == "" or path.startswith("/"), "Routed paths must always start '/'"
         self.path = path.rstrip("/")
         self.app = app
-        regex = "^" + self.path + "(?P<path>/.*)$"
-        regex = re.sub("{([a-zA-Z_][a-zA-Z0-9_]*)}", r"(?P<\1>[^/]*)", regex)
-        self.path_regex = re.compile(regex)
         self.name = name
+        self.path_regex, self.path_format, self.param_convertors = self.compile_path(
+            path + "/{path:path}"
+        )
 
     @property
     def routes(self) -> typing.List[BaseRoute]:
@@ -228,7 +293,9 @@ def matches(self, scope: Scope) -> typing.Tuple[Match, Scope]:
         match = self.path_regex.match(path)
         if match:
             matched_params = match.groupdict()
-            remaining_path = matched_params.pop("path")
+            for key, value in matched_params.items():
+                matched_params[key] = self.param_convertors[key].convert(value)
+            remaining_path = "/" + matched_params.pop("path")
             matched_path = path[: -len(remaining_path)]
             path_params = dict(scope.get("path_params", {}))
             path_params.update(matched_params)
@@ -245,7 +312,9 @@ def url_path_for(self, name: str, **path_params: str) -> URLPath:
         if self.name is not None and name == self.name and "path" in path_params:
             # 'name' matches "<mount_name>".
             path_params["path"] = path_params["path"].lstrip("/")
-            path, remaining_params = replace_params(self.path + "/{path}", path_params)
+            path, remaining_params = replace_params(
+                self.path_format, self.param_convertors, path_params
+            )
             if not remaining_params:
                 return URLPath(path=path, protocol="http")
         elif self.name is None or name.startswith(self.name + ":"):
@@ -255,11 +324,16 @@ def url_path_for(self, name: str, **path_params: str) -> URLPath:
             else:
                 # 'name' matches "<mount_name>:<child_name>".
                 remaining_name = name[len(self.name) + 1 :]
-            path, remaining_params = replace_params(self.path, path_params)
+            path_params["path"] = ""
+            path, remaining_params = replace_params(
+                self.path_format, self.param_convertors, path_params
+            )
             for route in self.routes or []:
                 try:
                     url = route.url_path_for(remaining_name, **remaining_params)
-                    return URLPath(path=path + str(url), protocol=url.protocol)
+                    return URLPath(
+                        path=path.rstrip("/") + str(url), protocol=url.protocol
+                    )
                 except NoMatchFound as exc:
                     pass
         raise NoMatchFound()


Test patch:
diff --git a/tests/test_routing.py b/tests/test_routing.py
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -1,7 +1,7 @@
 import pytest
 
 from starlette.exceptions import ExceptionMiddleware
-from starlette.responses import PlainTextResponse, Response
+from starlette.responses import JSONResponse, PlainTextResponse, Response
 from starlette.routing import Mount, NoMatchFound, Route, Router, WebSocketRoute
 from starlette.testclient import TestClient
 from starlette.websockets import WebSocket, WebSocketDisconnect
@@ -48,6 +48,24 @@ def contact(request):
     return Response("Hello, POST!", media_type="text/plain")
 
 
+@app.route("/int/{param:int}", name="int-convertor")
+def int_convertor(request):
+    number = request.path_params["param"]
+    return JSONResponse({"int": number})
+
+
+@app.route("/float/{param:float}", name="float-convertor")
+def float_convertor(request):
+    num = request.path_params["param"]
+    return JSONResponse({"float": num})
+
+
+@app.route("/path/{param:path}", name="path-convertor")
+def path_convertor(request):
+    path = request.path_params["param"]
+    return JSONResponse({"path": path})
+
+
 @app.websocket_route("/ws")
 async def websocket_endpoint(session):
     await session.accept()
@@ -91,12 +109,38 @@ def test_router():
     assert response.text == "xxxxx"
 
 
+def test_route_converters():
+    # Test integer conversion
+    response = client.get("/int/5")
+    assert response.status_code == 200
+    assert response.json() == {"int": 5}
+    assert app.url_path_for("int-convertor", param=5) == "/int/5"
+
+    # Test float conversion
+    response = client.get("/float/25.5")
+    assert response.status_code == 200
+    assert response.json() == {"float": 25.5}
+    assert app.url_path_for("float-convertor", param=25.5) == "/float/25.5"
+
+    # Test path conversion
+    response = client.get("/path/some/example")
+    assert response.status_code == 200
+    assert response.json() == {"path": "some/example"}
+    assert (
+        app.url_path_for("path-convertor", param="some/example") == "/path/some/example"
+    )
+
+
 def test_url_path_for():
     assert app.url_path_for("homepage") == "/"
     assert app.url_path_for("user", username="tomchristie") == "/users/tomchristie"
     assert app.url_path_for("websocket_endpoint") == "/ws"
     with pytest.raises(NoMatchFound):
         assert app.url_path_for("broken")
+    with pytest.raises(AssertionError):
+        app.url_path_for("user", username="tom/christie")
+    with pytest.raises(AssertionError):
+        app.url_path_for("user", username="")
 
 
 def test_url_for():