From dbfa5c3fdef83cda7946654c730ba10873dd249b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 07:11:00 +0000 Subject: [PATCH 1/7] Initial plan From cafd9fd9a39d5714c906adf814e7217afd77faac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 07:18:55 +0000 Subject: [PATCH 2/7] Add url_for method to Router class (issue #256) Co-authored-by: RobertoPrevato <2576032+RobertoPrevato@users.noreply.github.com> --- blacksheep/__init__.py | 1 + blacksheep/server/routing.py | 135 ++++++++++++++++++++++++----------- tests/test_router.py | 120 +++++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 43 deletions(-) diff --git a/blacksheep/__init__.py b/blacksheep/__init__.py index a6e0c034..87dda109 100644 --- a/blacksheep/__init__.py +++ b/blacksheep/__init__.py @@ -66,6 +66,7 @@ from .server.responses import unauthorized as unauthorized from .server.routing import Route as Route from .server.routing import RouteException as RouteException +from .server.routing import RouteNotFound as RouteNotFound from .server.routing import Router as Router from .server.routing import RoutesRegistry as RoutesRegistry from .server.routing import connect as connect diff --git a/blacksheep/server/routing.py b/blacksheep/server/routing.py index 9a8322ce..213a26ab 100644 --- a/blacksheep/server/routing.py +++ b/blacksheep/server/routing.py @@ -116,6 +116,15 @@ def __init__(self, parameter_pattern_name: str, matched_parameter: str) -> None: self.matched_parameter = matched_parameter +class RouteNotFound(RouteException): + def __init__(self, name: str) -> None: + super().__init__( + f"Cannot find a route with name: '{name}'. " + "Ensure the route is registered with the given name." + ) + self.name = name + + class RouteMatch: __slots__ = ("_values", "pattern", "handler") @@ -422,6 +431,16 @@ def match_by_path(self, path: bytes) -> RouteMatch | None: return RouteMatch(self, match.groupdict() if self.has_params else None) + def url(self, **params: str) -> str: + """ + Returns the URL for this route, substituting route parameters with + the given values. + """ + pattern = self.mustache_pattern + for param_name, param_value in params.items(): + pattern = pattern.replace(f"{{{param_name}}}", str(param_value)) + return pattern + class FilterRoute(Route): """ @@ -478,6 +497,8 @@ def add( method: str, pattern: str, handler: Callable, + *, + name: str | None = None, ) -> None: """Adds a request handler for the given HTTP method and route pattern.""" @@ -491,6 +512,7 @@ def _get_decorator( self, method: str, pattern: str | None = "/", + name: str | None = None, ) -> Callable[..., Any]: def decorator(fn): nonlocal pattern @@ -506,80 +528,80 @@ def decorator(fn): pattern, fn.__qualname__, ) - self.add(method, pattern, fn) + self.add(method, pattern, fn, name=name) return fn return decorator - def add_head(self, pattern: str, handler: Callable[..., Any]) -> None: - self.add(RouteMethod.HEAD, pattern, handler) + def add_head(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + self.add(RouteMethod.HEAD, pattern, handler, name=name) - def add_get(self, pattern: str, handler: Callable[..., Any]) -> None: - self.add(RouteMethod.GET, pattern, handler) + def add_get(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + self.add(RouteMethod.GET, pattern, handler, name=name) - def add_post(self, pattern: str, handler: Callable[..., Any]) -> None: - self.add(RouteMethod.POST, pattern, handler) + def add_post(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + self.add(RouteMethod.POST, pattern, handler, name=name) - def add_put(self, pattern: str, handler: Callable[..., Any]) -> None: - self.add(RouteMethod.PUT, pattern, handler) + def add_put(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + self.add(RouteMethod.PUT, pattern, handler, name=name) - def add_delete(self, pattern: str, handler: Callable[..., Any]) -> None: - self.add(RouteMethod.DELETE, pattern, handler) + def add_delete(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + self.add(RouteMethod.DELETE, pattern, handler, name=name) - def add_trace(self, pattern: str, handler: Callable[..., Any]) -> None: - self.add(RouteMethod.TRACE, pattern, handler) + def add_trace(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + self.add(RouteMethod.TRACE, pattern, handler, name=name) - def add_options(self, pattern: str, handler: Callable[..., Any]) -> None: - self.add(RouteMethod.OPTIONS, pattern, handler) + def add_options(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + self.add(RouteMethod.OPTIONS, pattern, handler, name=name) - def add_connect(self, pattern: str, handler: Callable[..., Any]) -> None: - self.add(RouteMethod.CONNECT, pattern, handler) + def add_connect(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + self.add(RouteMethod.CONNECT, pattern, handler, name=name) - def add_patch(self, pattern: str, handler: Callable[..., Any]) -> None: - self.add(RouteMethod.PATCH, pattern, handler) + def add_patch(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + self.add(RouteMethod.PATCH, pattern, handler, name=name) - def add_ws(self, pattern: str, handler: Callable[..., Any]) -> None: - self.add(RouteMethod.GET_WS, pattern, handler) + def add_ws(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + self.add(RouteMethod.GET_WS, pattern, handler, name=name) - def head(self, pattern: str | None = "/") -> Callable[..., Any]: - return self._get_decorator(RouteMethod.HEAD, pattern) + def head(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + return self._get_decorator(RouteMethod.HEAD, pattern, name) - def get(self, pattern: str | None = "/") -> Callable[..., Any]: - return self._get_decorator(RouteMethod.GET, pattern) + def get(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + return self._get_decorator(RouteMethod.GET, pattern, name) - def post(self, pattern: str | None = "/") -> Callable[..., Any]: - return self._get_decorator(RouteMethod.POST, pattern) + def post(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + return self._get_decorator(RouteMethod.POST, pattern, name) - def put(self, pattern: str | None = "/") -> Callable[..., Any]: - return self._get_decorator(RouteMethod.PUT, pattern) + def put(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + return self._get_decorator(RouteMethod.PUT, pattern, name) - def delete(self, pattern: str | None = "/") -> Callable[..., Any]: - return self._get_decorator(RouteMethod.DELETE, pattern) + def delete(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + return self._get_decorator(RouteMethod.DELETE, pattern, name) - def trace(self, pattern: str | None = "/") -> Callable[..., Any]: - return self._get_decorator(RouteMethod.TRACE, pattern) + def trace(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + return self._get_decorator(RouteMethod.TRACE, pattern, name) - def options(self, pattern: str | None = "/") -> Callable[..., Any]: - return self._get_decorator(RouteMethod.OPTIONS, pattern) + def options(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + return self._get_decorator(RouteMethod.OPTIONS, pattern, name) - def connect(self, pattern: str | None = "/") -> Callable[..., Any]: - return self._get_decorator(RouteMethod.CONNECT, pattern) + def connect(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + return self._get_decorator(RouteMethod.CONNECT, pattern, name) - def patch(self, pattern: str | None = "/") -> Callable[..., Any]: - return self._get_decorator(RouteMethod.PATCH, pattern) + def patch(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + return self._get_decorator(RouteMethod.PATCH, pattern, name) - def ws(self, pattern) -> Callable[..., Any]: - return self._get_decorator(RouteMethod.GET_WS, pattern) + def ws(self, pattern, *, name: str | None = None) -> Callable[..., Any]: + return self._get_decorator(RouteMethod.GET_WS, pattern, name) def route( - self, pattern: str, methods: Sequence[str] | None = None + self, pattern: str, methods: Sequence[str] | None = None, *, name: str | None = None ) -> Callable[..., Any]: if methods is None: methods = ["GET"] def decorator(f): for method in methods: - self.add(method, pattern, f) + self.add(method, pattern, f, name=name) return f return decorator @@ -670,6 +692,7 @@ class Router(RouterBase): "_filters", "_prefix", "_registered_routes", + "_named_routes", ) def __init__( @@ -698,6 +721,7 @@ def __init__( self.controllers_routes = RoutesRegistry() # used during controllers setup self._sub_routers = sub_routers self._registered_routes = [] # used during setup + self._named_routes: dict[str, Route] = {} if self._filters: extend(self, RouterFiltersMixin) @@ -714,6 +738,7 @@ def reset(self): self._map = {} self._fallback = None self.routes = defaultdict(list) + self._named_routes = {} self.controllers_routes.reset() if self._sub_routers: for sub_router in self._sub_routers: @@ -819,9 +844,13 @@ def add( pattern: AnyStr, handler: Any, filters: list[RouteFilter] | None = None, + *, + name: str | None = None, ): new_route = self.create_route(pattern, handler, filters) self._registered_routes.append((method, new_route)) + if name: + self._named_routes[name] = new_route def create_route( self, @@ -949,6 +978,24 @@ def get_matching_route(self, method: AnyStr, value: AnyStr) -> Route | None: return route return None + def url_for(self, name: str, **params: str) -> str: + """ + Returns the URL for a named route, substituting route parameters with + the given values. + + Raises RouteNotFound if no route with the given name is registered. + """ + route = self._named_routes.get(name) + if route is not None: + return route.url(**params) + if self._sub_routers: + for sub_router in self._sub_routers: + try: + return sub_router.url_for(name, **params) + except RouteNotFound: + pass + raise RouteNotFound(name) + class RegisteredRoute: __slots__ = ("method", "pattern", "handler") @@ -1001,6 +1048,8 @@ def add( method: str, pattern: str, handler: Callable, + *, + name: str | None = None, ): self.mark_handler(handler) self.routes.append(RegisteredRoute(method, pattern, handler)) diff --git a/tests/test_router.py b/tests/test_router.py index 72244bcb..51cb3ef5 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -11,6 +11,7 @@ RouteException, RouteFilter, RouteMethod, + RouteNotFound, Router, normalize_filters, ) @@ -1100,3 +1101,122 @@ def test_router_with_env_prefix(prefix): def test_router_with_combined_prefix(env_prefix, prefix): with modified_env(APP_ROUTE_PREFIX=env_prefix): _router_prefix_scenario_1(Router(prefix=prefix), env_prefix + prefix) + + +# Tests for url_for feature + +def test_url_for_simple_route(): + router = Router() + + @router.get("/", name="home") + def home(): ... + + router.apply_routes() + assert router.url_for("home") == "/" + + +def test_url_for_with_route_params(): + router = Router() + + @router.get("/users/:username", name="user_detail") + def user_detail(): ... + + router.apply_routes() + assert router.url_for("user_detail", username="johndoe") == "/users/johndoe" + + +def test_url_for_with_mustache_params(): + router = Router() + + @router.get("/cats/{cat_id}", name="get_cat") + def get_cat(): ... + + router.apply_routes() + assert router.url_for("get_cat", cat_id="123") == "/cats/123" + + +def test_url_for_with_typed_params(): + router = Router() + + @router.get("/cats/{int:cat_id}", name="get_cat") + def get_cat(): ... + + router.apply_routes() + assert router.url_for("get_cat", cat_id="42") == "/cats/42" + + +def test_url_for_with_multiple_params(): + router = Router() + + @router.get("/cats/:cat_id/friends/:friend_id", name="get_friend") + def get_friend(): ... + + router.apply_routes() + assert router.url_for("get_friend", cat_id="1", friend_id="2") == "/cats/1/friends/2" + + +def test_url_for_not_found_raises(): + router = Router() + + @router.get("/", name="home") + def home(): ... + + router.apply_routes() + + with pytest.raises(RouteNotFound): + router.url_for("nonexistent") + + +def test_url_for_with_prefix(): + router = Router(prefix="/api/v1") + + @router.get("/users", name="user_list") + def user_list(): ... + + router.apply_routes() + assert router.url_for("user_list") == "/api/v1/users" + + +def test_url_for_via_add_method(): + router = Router() + + def home(): ... + + router.add_get("/", home, name="home") + router.apply_routes() + assert router.url_for("home") == "/" + + +def test_url_for_via_add_shortcut(): + router = Router() + + def user_detail(): ... + + router.add("GET", "/users/:username", user_detail, name="user_detail") + router.apply_routes() + assert router.url_for("user_detail", username="alice") == "/users/alice" + + +def test_url_for_with_sub_router(): + sub_router = Router() + + @sub_router.get("/items/:item_id", name="get_item") + def get_item(): ... + + router = Router(sub_routers=[sub_router]) + + @router.get("/", name="home") + def home(): ... + + router.apply_routes() + + # Named route in sub-router is found + assert router.url_for("get_item", item_id="99") == "/items/99" + # Named route in main router still works + assert router.url_for("home") == "/" + + +def test_route_not_found_exception_has_name(): + exc = RouteNotFound("my_route") + assert exc.name == "my_route" + assert "my_route" in str(exc) From 85a43a42ac9eea9efe8c0b4bf2b9982cf60e6900 Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Sun, 22 Feb 2026 10:14:00 +0100 Subject: [PATCH 3/7] WIP --- blacksheep/server/application.py | 8 + blacksheep/server/files/dynamic.py | 2 +- blacksheep/server/normalization.py | 25 ++- blacksheep/server/routing.py | 51 ++++- tests/test_url_resolver.py | 325 +++++++++++++++++++++++++++++ tests/utils/application.py | 1 + 6 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 tests/test_url_resolver.py diff --git a/blacksheep/server/application.py b/blacksheep/server/application.py index c1ff4660..84127f8c 100644 --- a/blacksheep/server/application.py +++ b/blacksheep/server/application.py @@ -751,6 +751,7 @@ async def start(self): if not self.router.fallback: self.router.fallback = default_fallback + self.register_default_di_types() self.router.apply_routes() if self.on_start: @@ -773,6 +774,13 @@ async def stop(self): self.started = False self._started_complete.clear() + def register_default_di_types(self): + """ + Registers default DI types in the Application DI controller. + """ + if Router not in self._services: + self._services.register(Router, instance=self.router) + async def _handle_lifespan(self, receive, send) -> None: message = await receive() assert message["type"] == "lifespan.startup" diff --git a/blacksheep/server/files/dynamic.py b/blacksheep/server/files/dynamic.py index b344f354..92c67827 100644 --- a/blacksheep/server/files/dynamic.py +++ b/blacksheep/server/files/dynamic.py @@ -252,7 +252,7 @@ def serve_files_dynamic( *, discovery: bool, cache_time: int, - extensions: Set[str | None], + extensions: Set[str] | None, root_path: str, index_document: str | None, fallback_document: str | None, diff --git a/blacksheep/server/normalization.py b/blacksheep/server/normalization.py index 3ea51433..03f6d365 100644 --- a/blacksheep/server/normalization.py +++ b/blacksheep/server/normalization.py @@ -23,7 +23,7 @@ from blacksheep.messages import Request, Response from blacksheep.normalization import copy_special_attributes from blacksheep.server import responses -from blacksheep.server.routing import Route +from blacksheep.server.routing import Route, Router, URLResolver from blacksheep.server.sse import ServerSentEvent, ServerSentEventsResponse from blacksheep.server.websocket import WebSocket @@ -41,6 +41,29 @@ get_binder_by_type, ) + +class URLResolverBinder(Binder): + """ + Binder that injects a URLResolver into request handlers. + The URLResolver is constructed per-request from the singleton Router and the + current Request, so it correctly reflects the request's base_path for + generating URLs relative to the mount root. + """ + + type_alias = URLResolver + + def __init__(self, router: Router, implicit: bool = True): + super().__init__(URLResolver, implicit=implicit) + self._router = router + + @classmethod + def from_alias(cls, services: ContainerProtocol) -> "URLResolverBinder": + router: Router = services.resolve(Router) + return cls(router) + + async def get_value(self, request: Request) -> URLResolver: + return URLResolver(self._router, request) + _next_handler_binder = object() diff --git a/blacksheep/server/routing.py b/blacksheep/server/routing.py index 213a26ab..38b5c572 100644 --- a/blacksheep/server/routing.py +++ b/blacksheep/server/routing.py @@ -1147,7 +1147,6 @@ def validate_default_router(): if set(router): # The default router has routes defined, ensure that it is bound to an # application - # verify that try: _apps_by_router_id[id(router)] except KeyError: @@ -1155,6 +1154,56 @@ def validate_default_router(): raise OrphanDefaultRouterError() from None +class URLResolver: + """ + Resolves named route URLs in the context of the current request. + + Composes the router's named-route registry with the request's base_path, + so that generated URLs are correct when the application is served behind + a reverse proxy or mounted at a sub-path. + + This class is intended to be used as a scoped dependency injected into + request handlers. The framework wires both the singleton Router and the + current Request automatically, so handlers only need to declare it: + + async def handler(url_resolver: URLResolver) -> Response: + location = url_resolver.url_for("cat-detail", cat_id=42) + return redirect(location) + + Methods: + url_for(name, **params) -> str + Returns base_path + the path for the named route, with route + parameters substituted by the given values. + + absolute_url_for(name, **params) -> str + Returns a fully qualified URL (scheme + host + base_path + path) + for the named route. + """ + def __init__(self, router: Router, request: Request): + self._router = router + self._request = request + + def url_for(self, name: str, **params: str) -> str: + """Returns base_path + path for the named route. + + ``Router.url_for`` already includes the router's own prefix in the + returned path. The only extra prefix that must be prepended here is + the *external* root path set by an upstream reverse-proxy or by the + parent application when this app is mounted as a sub-application + (ASGI ``scope["root_path"]``). + """ + path = self._router.url_for(name, **params) + scope = getattr(self._request, "scope", None) + external_base = scope.get("root_path", "") if scope else "" + return f"{external_base}{path}" if external_base else path + + def absolute_url_for(self, name: str, **params: str) -> str: + """Returns a fully qualified URL including scheme and host.""" + path = self.url_for(name, **params) + req = self._request + return f"{req.scheme}://{req.host}{path}" + + # Singleton router used to store initial configuration, before the application starts. # This is used as *default* router, but it can be overridden. # This is done for two reasons: to reduce code verbosity when defining routes, diff --git a/tests/test_url_resolver.py b/tests/test_url_resolver.py new file mode 100644 index 00000000..68688021 --- /dev/null +++ b/tests/test_url_resolver.py @@ -0,0 +1,325 @@ +""" +Tests for URLResolver: the scoped dependency that generates named-route URLs +taking the request's external mount prefix (ASGI root_path) into account. + +Scenarios covered +----------------- +1. Basic usage – no router prefix, no mount: url_for returns the plain route path. +2. Router with a prefix – the prefix appears once in the result (it comes from + Router.url_for; the external root_path is empty). +3. Child app mounted at a sub-path – the mount prefix is prepended by reading + scope["root_path"] that the parent's MountMixin sets. +4. Child app with its own router prefix, mounted at a sub-path – mount prefix + and router prefix both appear, but the router prefix is not doubled. +5. absolute_url_for – validates that the scheme and host are prepended correctly. +6. absolute_url_for behind a reverse proxy – root_path from an upstream proxy + is reflected in the absolute URL. +7. Injection verification – asserts that declaring ``url_resolver: URLResolver`` + in a handler signature is sufficient (no extra DI setup required). +""" + +import pytest + +from blacksheep import Response +from blacksheep.server.application import Application +from blacksheep.server.routing import Router, URLResolver +from blacksheep.server.responses import redirect +from blacksheep.testing.helpers import get_example_scope +from blacksheep.testing.messages import MockReceive, MockSend +from tests.utils.application import FakeApplication + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _scope(method: str, path: str, *, root_path: str = "", host: str = "localhost"): + """Build a minimal ASGI scope with an explicit root_path.""" + scope = get_example_scope(method, path, {"host": host}) + scope["root_path"] = root_path + return scope + + +async def _call(app: FakeApplication, scope: dict) -> Response: + await app(scope, MockReceive(), MockSend()) + assert app.response is not None + return app.response + + +def _location(response: Response) -> str: + header = response.get_first_header(b"location") + assert header is not None, "Response has no Location header" + return header.decode() + + +# --------------------------------------------------------------------------- +# 1. Basic – no prefix, no mount +# --------------------------------------------------------------------------- + + +async def test_url_resolver_basic_resolves_named_route(): + """url_for returns the plain route path when there is no prefix or mount.""" + app = FakeApplication(router=Router()) + + @app.router.get("/cats/{cat_id}", name="cat-detail") + async def cat_detail(cat_id: int) -> Response: + return Response(200) + + @app.router.get("/redirect") + async def redirect_handler(url_resolver: URLResolver) -> Response: + return redirect(url_resolver.url_for("cat-detail", cat_id="42")) + + await app.start() + + response = await _call(app, _scope("GET", "/redirect")) + + assert response.status == 302 + assert _location(response) == "/cats/42" + + +# --------------------------------------------------------------------------- +# 2. Router with prefix="/api/v1" – prefix appears exactly once in the URL +# --------------------------------------------------------------------------- + + +async def test_url_resolver_with_router_prefix_no_double_prefix(): + """ + When the router has a prefix, Router.url_for already includes it. + URLResolver must NOT prepend it a second time via base_path. + """ + app = FakeApplication(router=Router(prefix="/api/v1")) + + @app.router.get("/cats/{cat_id}", name="cat-detail") + async def cat_detail(cat_id: int) -> Response: + return Response(200) + + @app.router.get("/redirect") + async def redirect_handler(url_resolver: URLResolver) -> Response: + return redirect(url_resolver.url_for("cat-detail", cat_id="42")) + + await app.start() + + response = await _call(app, _scope("GET", "/api/v1/redirect")) + + assert response.status == 302 + location = _location(response) + assert location == "/api/v1/cats/42", ( + f"Expected /api/v1/cats/42 but got {location!r}. " + "The router prefix must appear exactly once." + ) + + +# --------------------------------------------------------------------------- +# 3. Mounted child app – no child prefix +# --------------------------------------------------------------------------- + + +async def test_url_resolver_in_mounted_child_app_prepends_mount_prefix(): + """ + The parent mounts the child at '/sub'. + MountMixin sets scope["root_path"] = "/sub" before delegating to the child. + URLResolver.url_for must prepend "/sub" so the generated URL is absolute + with respect to the host root. + """ + parent_app = FakeApplication(router=Router()) + child_app = FakeApplication(router=Router()) + + @child_app.router.get("/cats/{cat_id}", name="cat-detail") + async def cat_detail(cat_id: int) -> Response: + return Response(200) + + @child_app.router.get("/redirect") + async def redirect_handler(url_resolver: URLResolver) -> Response: + return redirect(url_resolver.url_for("cat-detail", cat_id="7")) + + parent_app.mount("/sub", child_app) + await parent_app.start() + await child_app.start() + + await parent_app(_scope("GET", "/sub/redirect"), MockReceive(), MockSend()) + response = child_app.response + assert response is not None + + assert response.status == 302 + location = _location(response) + assert location == "/sub/cats/7", ( + f"Expected /sub/cats/7 but got {location!r}. " + "The mount prefix must be prepended to the generated URL." + ) + + +# --------------------------------------------------------------------------- +# 4. Mounted child app WITH a router prefix +# --------------------------------------------------------------------------- + + +async def test_url_resolver_in_mounted_child_app_with_child_prefix_no_double_prefix(): + """ + Parent mounts child at '/sub'; child router has prefix='/api'. + The generated URL must be '/sub/api/cats/99' – mount prefix once, + router prefix once, not doubled. + """ + parent_app = FakeApplication() + child_app = FakeApplication(router=Router(prefix="/api")) + + @child_app.router.get("/cats/{cat_id}", name="cat-detail") + async def cat_detail(cat_id: int) -> Response: + return Response(200) + + @child_app.router.get("/redirect") + async def redirect_handler(url_resolver: URLResolver) -> Response: + return redirect(url_resolver.url_for("cat-detail", cat_id="99")) + + parent_app.mount("/sub", child_app) + await parent_app.start() + await child_app.start() + + await parent_app(_scope("GET", "/sub/api/redirect"), MockReceive(), MockSend()) + response = child_app.response + + assert response is not None + assert response.status == 302 + location = _location(response) + assert location == "/sub/api/cats/99", ( + f"Expected /sub/api/cats/99 but got {location!r}. " + "Mount prefix and router prefix must each appear exactly once." + ) + + +# --------------------------------------------------------------------------- +# 5. absolute_url_for – basic +# --------------------------------------------------------------------------- + + +async def test_url_resolver_absolute_url_for(): + """absolute_url_for returns scheme + host + path for a named route.""" + app = FakeApplication(router=Router()) + captured: list[str] = [] + + @app.router.get("/cats/{cat_id}", name="cat-detail") + async def cat_detail(cat_id: int) -> Response: + return Response(200) + + @app.router.get("/redirect") + async def redirect_handler(url_resolver: URLResolver) -> Response: + captured.append(url_resolver.absolute_url_for("cat-detail", cat_id="5")) + return Response(200) + + await app.start() + await _call(app, _scope("GET", "/redirect", host="example.com")) + + assert len(captured) == 1 + assert captured[0] == "http://example.com/cats/5" + + +# --------------------------------------------------------------------------- +# 6. absolute_url_for behind a reverse proxy (scope root_path set) +# --------------------------------------------------------------------------- + + +async def test_url_resolver_absolute_url_for_behind_reverse_proxy(): + """ + When a reverse proxy sets scope["root_path"] (e.g. the app is served under + /myapp), absolute_url_for must include that prefix. + """ + app = FakeApplication(router=Router()) + captured: list[str] = [] + + @app.router.get("/cats/{cat_id}", name="cat-detail") + async def cat_detail(cat_id: int) -> Response: + return Response(200) + + @app.router.get("/redirect") + async def redirect_handler(url_resolver: URLResolver) -> Response: + captured.append(url_resolver.absolute_url_for("cat-detail", cat_id="3")) + return Response(200) + + await app.start() + await _call( + app, + _scope("GET", "/redirect", root_path="/myapp", host="example.com"), + ) + + assert len(captured) == 1 + assert captured[0] == "http://example.com/myapp/cats/3" + + +# --------------------------------------------------------------------------- +# 7. Injection verification – no extra DI setup required +# --------------------------------------------------------------------------- + + +async def test_url_resolver_injected_without_di_scope_middleware(): + """ + Declaring ``url_resolver: URLResolver`` in a handler signature must work + out of the box, without calling register_http_context or adding + di_scope_middleware. The URLResolverBinder handles this transparently. + """ + app = FakeApplication(router=Router()) + injected_instance: list[URLResolver] = [] + + @app.router.get("/items/{item_id}", name="item-detail") + async def item_detail(item_id: str) -> Response: + return Response(200) + + @app.router.get("/check") + async def check_handler(url_resolver: URLResolver) -> Response: + injected_instance.append(url_resolver) + return Response(200, content=None) + + await app.start() + await _call(app, _scope("GET", "/check")) + + assert len(injected_instance) == 1 + assert isinstance(injected_instance[0], URLResolver) + + +# --------------------------------------------------------------------------- +# 8. Unit-level: URLResolver.url_for with explicit scope root_path +# --------------------------------------------------------------------------- + + +async def test_url_resolver_unit_with_explicit_root_path(): + """ + Unit test: construct a URLResolver directly, set scope["root_path"] on the + request, and verify url_for prepends the external base. + """ + from blacksheep.messages import Request + + router = Router() + + @router.get("/widgets/{widget_id}", name="widget-detail") + def widget_detail(widget_id: str) -> Response: + return Response(200) + + router.apply_routes() + + request = Request("GET", b"/widgets/redirect", None) + request.scope = {"root_path": "/tenant"} # type: ignore[assignment] + + resolver = URLResolver(router, request) + assert resolver.url_for("widget-detail", widget_id="9") == "/tenant/widgets/9" + + +async def test_url_resolver_unit_no_root_path(): + """ + Unit test: when scope["root_path"] is absent or empty, url_for returns the + plain path from Router.url_for (which already includes any router prefix). + """ + from blacksheep.messages import Request + + router = Router(prefix="/v2") + + @router.get("/things/{thing_id}", name="thing-detail") + def thing_detail(thing_id: str) -> Response: + return Response(200) + + router.apply_routes() + + request = Request("GET", b"/v2/things/redirect", None) + request.scope = {"root_path": ""} # type: ignore[assignment] + + resolver = URLResolver(router, request) + # Router.url_for already includes /v2 prefix; no external base → no doubling + assert resolver.url_for("thing-detail", thing_id="1") == "/v2/things/1" diff --git a/tests/utils/application.py b/tests/utils/application.py index 3100fbbf..d02345f3 100644 --- a/tests/utils/application.py +++ b/tests/utils/application.py @@ -12,6 +12,7 @@ def __init__(self, *args, **kwargs): self.auto_start: bool = True self.request: Request | None = None self.response: Response | None = None + self.register_default_di_types() @deprecated( "This function is not needed anymore, and will be removed. Rely instead on " From 8816d0d9773f9bdc9c9db565f94290737928b60d Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Sun, 22 Feb 2026 10:22:33 +0100 Subject: [PATCH 4/7] WIP --- blacksheep/server/bindings/__init__.py | 24 ++++++++++++++++++++++++ blacksheep/server/normalization.py | 24 +----------------------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/blacksheep/server/bindings/__init__.py b/blacksheep/server/bindings/__init__.py index 4966c279..90bd3407 100644 --- a/blacksheep/server/bindings/__init__.py +++ b/blacksheep/server/bindings/__init__.py @@ -31,6 +31,7 @@ from blacksheep.contents import FormPart from blacksheep.exceptions import BadRequest from blacksheep.server.bindings.converters import class_converters, converters +from blacksheep.server.routing import Router, URLResolver from blacksheep.server.websocket import WebSocket from blacksheep.url import URL @@ -870,3 +871,26 @@ class FilesBinder(Binder): async def get_value(self, request: Request) -> list[FormPart]: return await request.files() + + +class URLResolverBinder(Binder): + """ + Binder that injects a URLResolver into request handlers. + The URLResolver is constructed per-request from the singleton Router and the + current Request, so it correctly reflects the request's base_path for + generating URLs relative to the mount root. + """ + + type_alias = URLResolver + + def __init__(self, router: Router, implicit: bool = True): + super().__init__(URLResolver, implicit=implicit) + self._router = router + + @classmethod + def from_alias(cls, services: ContainerProtocol) -> "URLResolverBinder": + router: Router = services.resolve(Router) + return cls(router) + + async def get_value(self, request: Request) -> URLResolver: + return URLResolver(self._router, request) diff --git a/blacksheep/server/normalization.py b/blacksheep/server/normalization.py index 03f6d365..ab2d7ad2 100644 --- a/blacksheep/server/normalization.py +++ b/blacksheep/server/normalization.py @@ -23,7 +23,7 @@ from blacksheep.messages import Request, Response from blacksheep.normalization import copy_special_attributes from blacksheep.server import responses -from blacksheep.server.routing import Route, Router, URLResolver +from blacksheep.server.routing import Route from blacksheep.server.sse import ServerSentEvent, ServerSentEventsResponse from blacksheep.server.websocket import WebSocket @@ -42,28 +42,6 @@ ) -class URLResolverBinder(Binder): - """ - Binder that injects a URLResolver into request handlers. - The URLResolver is constructed per-request from the singleton Router and the - current Request, so it correctly reflects the request's base_path for - generating URLs relative to the mount root. - """ - - type_alias = URLResolver - - def __init__(self, router: Router, implicit: bool = True): - super().__init__(URLResolver, implicit=implicit) - self._router = router - - @classmethod - def from_alias(cls, services: ContainerProtocol) -> "URLResolverBinder": - router: Router = services.resolve(Router) - return cls(router) - - async def get_value(self, request: Request) -> URLResolver: - return URLResolver(self._router, request) - _next_handler_binder = object() From 378fa2481680e6a6488910b3d77737cb63e07bb0 Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Sun, 22 Feb 2026 21:08:55 +0100 Subject: [PATCH 5/7] Improve handling of URLResolverBinder --- blacksheep/server/application.py | 12 ++-- blacksheep/server/bindings/__init__.py | 9 +-- blacksheep/server/normalization.py | 1 - blacksheep/server/routing.py | 83 +++++++++++++++++++------- tests/test_files_serving.py | 2 +- tests/test_router.py | 5 +- tests/test_url_resolver.py | 3 +- 7 files changed, 81 insertions(+), 34 deletions(-) diff --git a/blacksheep/server/application.py b/blacksheep/server/application.py index 84127f8c..6797ceb6 100644 --- a/blacksheep/server/application.py +++ b/blacksheep/server/application.py @@ -777,9 +777,10 @@ async def stop(self): def register_default_di_types(self): """ Registers default DI types in the Application DI controller. + By default, only the Application object is registered in the DI controller. """ - if Router not in self._services: - self._services.register(Router, instance=self.router) + if Application not in self._services: + self._services.register(Application, instance=self) async def _handle_lifespan(self, receive, send) -> None: message = await receive() @@ -920,7 +921,9 @@ def handle_mount_path(self, scope, route_match): # Update root_path per the ASGI spec: the child app must know its mount # prefix so it can generate correct absolute URLs (analogous to WSGI # SCRIPT_NAME). root_path = parent root_path + the stripped mount prefix. - mount_prefix = scope["path"][: -len(tail)] if tail != "/" else scope["path"].rstrip("/") + mount_prefix = ( + scope["path"][: -len(tail)] if tail != "/" else scope["path"].rstrip("/") + ) scope["root_path"] = scope.get("root_path", "") + mount_prefix scope["path"] = tail @@ -960,8 +963,7 @@ async def _handle_redirect_to_mount_root(self, scope, send): ) await send_asgi_response(response, send) - def _get_request_scope(self, scope): - ... + def _get_request_scope(self, scope): ... async def __call__(self, scope, receive, send): if scope["type"] == "lifespan": diff --git a/blacksheep/server/bindings/__init__.py b/blacksheep/server/bindings/__init__.py index 90bd3407..4ad0cb3b 100644 --- a/blacksheep/server/bindings/__init__.py +++ b/blacksheep/server/bindings/__init__.py @@ -8,7 +8,6 @@ https://www.neoteroi.dev/blacksheep/binders/ """ -import warnings from abc import abstractmethod from collections.abc import Iterable as IterableAbc from functools import partial @@ -27,9 +26,9 @@ from guardpost import Identity from rodi import CannotResolveTypeException, ContainerProtocol -from blacksheep import Request from blacksheep.contents import FormPart from blacksheep.exceptions import BadRequest +from blacksheep.messages import Request from blacksheep.server.bindings.converters import class_converters, converters from blacksheep.server.routing import Router, URLResolver from blacksheep.server.websocket import WebSocket @@ -889,8 +888,10 @@ def __init__(self, router: Router, implicit: bool = True): @classmethod def from_alias(cls, services: ContainerProtocol) -> "URLResolverBinder": - router: Router = services.resolve(Router) - return cls(router) + from blacksheep.server import Application + + app: Application = services.resolve(Application) + return cls(app.router) async def get_value(self, request: Request) -> URLResolver: return URLResolver(self._router, request) diff --git a/blacksheep/server/normalization.py b/blacksheep/server/normalization.py index ab2d7ad2..3ea51433 100644 --- a/blacksheep/server/normalization.py +++ b/blacksheep/server/normalization.py @@ -41,7 +41,6 @@ get_binder_by_type, ) - _next_handler_binder = object() diff --git a/blacksheep/server/routing.py b/blacksheep/server/routing.py index 38b5c572..f5d384ff 100644 --- a/blacksheep/server/routing.py +++ b/blacksheep/server/routing.py @@ -533,68 +533,110 @@ def decorator(fn): return decorator - def add_head(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + def add_head( + self, pattern: str, handler: Callable[..., Any], *, name: str | None = None + ) -> None: self.add(RouteMethod.HEAD, pattern, handler, name=name) - def add_get(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + def add_get( + self, pattern: str, handler: Callable[..., Any], *, name: str | None = None + ) -> None: self.add(RouteMethod.GET, pattern, handler, name=name) - def add_post(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + def add_post( + self, pattern: str, handler: Callable[..., Any], *, name: str | None = None + ) -> None: self.add(RouteMethod.POST, pattern, handler, name=name) - def add_put(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + def add_put( + self, pattern: str, handler: Callable[..., Any], *, name: str | None = None + ) -> None: self.add(RouteMethod.PUT, pattern, handler, name=name) - def add_delete(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + def add_delete( + self, pattern: str, handler: Callable[..., Any], *, name: str | None = None + ) -> None: self.add(RouteMethod.DELETE, pattern, handler, name=name) - def add_trace(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + def add_trace( + self, pattern: str, handler: Callable[..., Any], *, name: str | None = None + ) -> None: self.add(RouteMethod.TRACE, pattern, handler, name=name) - def add_options(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + def add_options( + self, pattern: str, handler: Callable[..., Any], *, name: str | None = None + ) -> None: self.add(RouteMethod.OPTIONS, pattern, handler, name=name) - def add_connect(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + def add_connect( + self, pattern: str, handler: Callable[..., Any], *, name: str | None = None + ) -> None: self.add(RouteMethod.CONNECT, pattern, handler, name=name) - def add_patch(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + def add_patch( + self, pattern: str, handler: Callable[..., Any], *, name: str | None = None + ) -> None: self.add(RouteMethod.PATCH, pattern, handler, name=name) - def add_ws(self, pattern: str, handler: Callable[..., Any], *, name: str | None = None) -> None: + def add_ws( + self, pattern: str, handler: Callable[..., Any], *, name: str | None = None + ) -> None: self.add(RouteMethod.GET_WS, pattern, handler, name=name) - def head(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + def head( + self, pattern: str | None = "/", *, name: str | None = None + ) -> Callable[..., Any]: return self._get_decorator(RouteMethod.HEAD, pattern, name) - def get(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + def get( + self, pattern: str | None = "/", *, name: str | None = None + ) -> Callable[..., Any]: return self._get_decorator(RouteMethod.GET, pattern, name) - def post(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + def post( + self, pattern: str | None = "/", *, name: str | None = None + ) -> Callable[..., Any]: return self._get_decorator(RouteMethod.POST, pattern, name) - def put(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + def put( + self, pattern: str | None = "/", *, name: str | None = None + ) -> Callable[..., Any]: return self._get_decorator(RouteMethod.PUT, pattern, name) - def delete(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + def delete( + self, pattern: str | None = "/", *, name: str | None = None + ) -> Callable[..., Any]: return self._get_decorator(RouteMethod.DELETE, pattern, name) - def trace(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + def trace( + self, pattern: str | None = "/", *, name: str | None = None + ) -> Callable[..., Any]: return self._get_decorator(RouteMethod.TRACE, pattern, name) - def options(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + def options( + self, pattern: str | None = "/", *, name: str | None = None + ) -> Callable[..., Any]: return self._get_decorator(RouteMethod.OPTIONS, pattern, name) - def connect(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + def connect( + self, pattern: str | None = "/", *, name: str | None = None + ) -> Callable[..., Any]: return self._get_decorator(RouteMethod.CONNECT, pattern, name) - def patch(self, pattern: str | None = "/", *, name: str | None = None) -> Callable[..., Any]: + def patch( + self, pattern: str | None = "/", *, name: str | None = None + ) -> Callable[..., Any]: return self._get_decorator(RouteMethod.PATCH, pattern, name) def ws(self, pattern, *, name: str | None = None) -> Callable[..., Any]: return self._get_decorator(RouteMethod.GET_WS, pattern, name) def route( - self, pattern: str, methods: Sequence[str] | None = None, *, name: str | None = None + self, + pattern: str, + methods: Sequence[str] | None = None, + *, + name: str | None = None, ) -> Callable[..., Any]: if methods is None: methods = ["GET"] @@ -1179,6 +1221,7 @@ async def handler(url_resolver: URLResolver) -> Response: Returns a fully qualified URL (scheme + host + base_path + path) for the named route. """ + def __init__(self, router: Router, request: Request): self._router = router self._request = request diff --git a/tests/test_files_serving.py b/tests/test_files_serving.py index 61cff17f..a9df45d8 100644 --- a/tests/test_files_serving.py +++ b/tests/test_files_serving.py @@ -7,7 +7,6 @@ from essentials.folders import get_file_extension from blacksheep import Application, Request -from blacksheep.server.routing import Router from blacksheep.common.files.asyncfs import FileContext, FilesHandler from blacksheep.exceptions import BadRequest, InvalidArgument from blacksheep.ranges import Range, RangePart @@ -25,6 +24,7 @@ from blacksheep.server.headers.cache import CacheControlHeaderValue from blacksheep.server.resources import get_resource_file_path from blacksheep.server.responses import text +from blacksheep.server.routing import Router from blacksheep.testing.helpers import get_example_scope from blacksheep.testing.messages import MockReceive, MockSend from blacksheep.utils.aio import get_running_loop diff --git a/tests/test_router.py b/tests/test_router.py index 51cb3ef5..544d3b57 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1105,6 +1105,7 @@ def test_router_with_combined_prefix(env_prefix, prefix): # Tests for url_for feature + def test_url_for_simple_route(): router = Router() @@ -1152,7 +1153,9 @@ def test_url_for_with_multiple_params(): def get_friend(): ... router.apply_routes() - assert router.url_for("get_friend", cat_id="1", friend_id="2") == "/cats/1/friends/2" + assert ( + router.url_for("get_friend", cat_id="1", friend_id="2") == "/cats/1/friends/2" + ) def test_url_for_not_found_raises(): diff --git a/tests/test_url_resolver.py b/tests/test_url_resolver.py index 68688021..41f0fc01 100644 --- a/tests/test_url_resolver.py +++ b/tests/test_url_resolver.py @@ -22,13 +22,12 @@ from blacksheep import Response from blacksheep.server.application import Application -from blacksheep.server.routing import Router, URLResolver from blacksheep.server.responses import redirect +from blacksheep.server.routing import Router, URLResolver from blacksheep.testing.helpers import get_example_scope from blacksheep.testing.messages import MockReceive, MockSend from tests.utils.application import FakeApplication - # --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- From 1539b821288a7d7e8ae156b310a0145410b24886 Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Sun, 22 Feb 2026 21:38:12 +0100 Subject: [PATCH 6/7] Update CHANGELOG.md --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa70ce5d..fbd7f0af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Static files are now served with `Content-Length` header instead of `Transfer-Encoding: chunked` when file size is known. This improves compatibility with `WSGI` servers via `a2wsgi`. - Automatically run the `Application` start logic if the `__call__` method is called with **http** or **websocket** messages. This is useful when `lifespan` events are not supported, like when using `WSGI`. - Fix the issue [#396](https://github.com/Neoteroi/BlackSheep/issues/396). Requests for mounted apps are redirected to a directory (ending with '/') only if the request includes a `Sec-Fetch-Mode: navigate`, which is used by modern browsers to inform the server the request is for navigation. This way, mounted apps serving HTML documents containing relative links work properly (their path must end with `/`). Reported by @satori1995. +- Fix the issue [#256](https://github.com/Neoteroi/BlackSheep/issues/256): add support + for configuring names for routes, and for obtaining URLs by route name. Example: + + ```python + from blacksheep.routing import URLResolver + + + @app.router.get("/cats/{cat_id}", name="cat-detail") + async def get_cat_detail(cat_id: int) -> Response: + return Response(200) + + @app.router.get("/redirect") + async def redirect_handler(url_resolver: URLResolver) -> Response: + return redirect(url_resolver.url_for("cat-detail", cat_id="42")) + ``` ## [2.6.0] - 2026-02-15 :cupid: From 08f8046628490739923bde83ae99517293fe3285 Mon Sep 17 00:00:00 2001 From: Roberto Prevato Date: Sun, 22 Feb 2026 21:58:28 +0100 Subject: [PATCH 7/7] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd7f0af..7c821aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.6.1] - 2026-??-?? +## [2.6.1] - 2026-02-22 :cat: - Fix missing escaping in `multipart/form-data` filenames and content-disposition headers. - Fix [#193](https://github.com/Neoteroi/BlackSheep/issues/193), adding support for [`a2wsgi`](https://github.com/abersheeran/a2wsgi).