From 019857b56877eb511f61123726c4ad9bfcc51b0e Mon Sep 17 00:00:00 2001 From: Bodo Graumann Date: Tue, 31 May 2022 10:56:20 +0200 Subject: [PATCH 1/2] Allow colons in routes --- starlette/routing.py | 9 ++++++--- tests/test_routing.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/starlette/routing.py b/starlette/routing.py index 7e10b16f9..19a84cb90 100644 --- a/starlette/routing.py +++ b/starlette/routing.py @@ -108,7 +108,7 @@ def replace_params( def compile_path( - path: str, + path: str, strip_port: bool = False ) -> typing.Tuple[typing.Pattern, str, typing.Dict[str, Convertor]]: """ Given a path string, like: "/{username:str}", return a three-tuple @@ -150,7 +150,8 @@ def compile_path( ending = "s" if len(duplicated_params) > 1 else "" raise ValueError(f"Duplicated param name{ending} {names} at path {path}") - path_regex += re.escape(path[idx:].split(":")[0]) + "$" + tail: str = path[idx:].split(":")[0] if strip_port else path[idx:] + path_regex += re.escape(tail) + "$" path_format += path[idx:] return re.compile(path_regex), path_format, param_convertors @@ -432,7 +433,9 @@ def __init__( self.host = host self.app = app self.name = name - self.host_regex, self.host_format, self.param_convertors = compile_path(host) + self.host_regex, self.host_format, self.param_convertors = compile_path( + host, strip_port=True + ) @property def routes(self) -> typing.List[BaseRoute]: diff --git a/tests/test_routing.py b/tests/test_routing.py index e8adaca48..5d69dcc01 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -18,6 +18,11 @@ def users(request): return Response("All users", media_type="text/plain") +def disable_user(request): + content = "User " + request.path_params["username"] + " disabled" + return Response(content, media_type="text/plain") + + def user(request): content = "User " + request.path_params["username"] return Response(content, media_type="text/plain") @@ -108,6 +113,7 @@ async def websocket_params(session: WebSocket): routes=[ Route("/", endpoint=users), Route("/me", endpoint=user_me), + Route("/{username}:disable", endpoint=disable_user, methods=["PUT"]), Route("/{username}", endpoint=user), Route("/nomatch", endpoint=user_no_match), ], @@ -189,6 +195,11 @@ def test_router(client): assert response.url == "http://testserver/users/tomchristie" assert response.text == "User tomchristie" + response = client.put("/users/tomchristie:disable") + assert response.status_code == 200 + assert response.url == "http://testserver/users/tomchristie:disable" + assert response.text == "User tomchristie disabled" + response = client.get("/users/nomatch") assert response.status_code == 200 assert response.text == "User nomatch" From 5aef9ab432d3a66716642b03dcc16ebde4f99d72 Mon Sep 17 00:00:00 2001 From: Bodo Graumann Date: Wed, 1 Jun 2022 16:54:43 +0200 Subject: [PATCH 2/2] Make compile_path behaviour implicit but document it --- starlette/routing.py | 39 +++++++++++++++++++++++++++------------ tests/test_routing.py | 5 +++-- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/starlette/routing.py b/starlette/routing.py index 19a84cb90..b649b7757 100644 --- a/starlette/routing.py +++ b/starlette/routing.py @@ -108,34 +108,47 @@ def replace_params( def compile_path( - path: str, strip_port: bool = False + pattern: str, ) -> typing.Tuple[typing.Pattern, str, typing.Dict[str, Convertor]]: """ - Given a path string, like: "/{username:str}", return a three-tuple + Can compile hostname patterns as well as path patterns. When the patterns + starts with a slash it is regarded as a path, otherwise as a hostname. + + When given a path pattern, like: "/{username:str}", return a three-tuple of (regex, format, {param_name:convertor}). regex: "/(?P[^/]+)" format: "/{username}" convertors: {"username": StringConvertor()} + + When given a hostname pattern, like: "{subdomain:str}.mydomain.tld:8080", + return a three-tuple of (regex, format, {param_name:convertor}). + + regex: "(?P[^/]+).mydomain.tld" + format: "{subdomain}.mydomain.tld:8080" + convertors: {"subdomain": StringConvertor()} + + The regex is used for parsing URIs, while the format is used to generate URIs. """ + is_host: bool = not pattern.startswith("/") path_regex = "^" path_format = "" duplicated_params = set() idx = 0 param_convertors = {} - for match in PARAM_REGEX.finditer(path): + for match in PARAM_REGEX.finditer(pattern): param_name, convertor_type = match.groups("str") convertor_type = convertor_type.lstrip(":") assert ( convertor_type in CONVERTOR_TYPES - ), f"Unknown path convertor '{convertor_type}'" + ), f"Unknown param convertor '{convertor_type}'" convertor = CONVERTOR_TYPES[convertor_type] - path_regex += re.escape(path[idx : match.start()]) + path_regex += re.escape(pattern[idx : match.start()]) path_regex += f"(?P<{param_name}>{convertor.regex})" - path_format += path[idx : match.start()] + path_format += pattern[idx : match.start()] path_format += "{%s}" % param_name if param_name in param_convertors: @@ -148,11 +161,14 @@ def compile_path( if duplicated_params: names = ", ".join(sorted(duplicated_params)) ending = "s" if len(duplicated_params) > 1 else "" - raise ValueError(f"Duplicated param name{ending} {names} at path {path}") + input_type = "host" if is_host else "path" + raise ValueError( + f"Duplicated param name{ending} {names} at {input_type} pattern {pattern}" + ) - tail: str = path[idx:].split(":")[0] if strip_port else path[idx:] + tail: str = pattern[idx:].split(":")[0] if is_host else pattern[idx:] path_regex += re.escape(tail) + "$" - path_format += path[idx:] + path_format += pattern[idx:] return re.compile(path_regex), path_format, param_convertors @@ -430,12 +446,11 @@ class Host(BaseRoute): def __init__( self, host: str, app: ASGIApp, name: typing.Optional[str] = None ) -> None: + assert not host.startswith("/"), "Routed hosts must not start with '/'" self.host = host self.app = app self.name = name - self.host_regex, self.host_format, self.param_convertors = compile_path( - host, strip_port=True - ) + self.host_regex, self.host_format, self.param_convertors = compile_path(host) @property def routes(self) -> typing.List[BaseRoute]: diff --git a/tests/test_routing.py b/tests/test_routing.py index 5d69dcc01..e70fa60c9 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -713,13 +713,14 @@ def test_partial_async_ws_endpoint(test_client_factory): def test_duplicated_param_names(): with pytest.raises( ValueError, - match="Duplicated param name id at path /{id}/{id}", + match="Duplicated param name id at path pattern /{id}/{id}", ): Route("/{id}/{id}", user) with pytest.raises( ValueError, - match="Duplicated param names id, name at path /{id}/{name}/{id}/{name}", + match="Duplicated param names id, name" + " at path pattern /{id}/{name}/{id}/{name}", ): Route("/{id}/{name}/{id}/{name}", user)