Repository: Neoteroi/BlackSheep
Pull request #556 (2025-05-08 20:32:59)

Feature/controllers inheritance
Fix [#511](https://github.com/Neoteroi/BlackSheep/issues/511). Add support for inheriting endpoints from parent controller classes, when subclassing controllers. An important feature that was missing so far in the web framework. 
Example:

```python
from blacksheep import Application
from blacksheep.server.controllers import Controller, abstract, get

app = Application()


@abstract()
class BaseController(Controller):
    @get("/hello-world")
    def index(self):
        # Note: the route /hello-world itself will not be registered in the router,
        # because this class is decorated with @abstract()
        return self.text(f"Hello, World! {self.__class__.__name__}")


class ControllerOne(BaseController):
    route = "/one"

    # /one/hello-world


class ControllerTwo(BaseController):
    route = "/two"

    # /two/hello-world

    @get("/specific-route")  # /two/specific-route
    def specific_route(self):
        return self.text("This is a specific route in ControllerTwo")
```

- Add a new `@abstract()` decorator that can be applied to controller classes to skip
  routes defined on them (only inherited classes will have the routes, prefixed by a
  route).
- **BREAKING CHANGE**. Refactor the `Application` code to encapsulate in a dedicated class functions that prepare controllers' routes.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba24381..39a897c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,8 +5,47 @@ 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.2.1] - 2025-??-??
+## [2.3.0] - 2025-??-??
 
+- Fix [#511](https://github.com/Neoteroi/BlackSheep/issues/511). Add support for
+  inheriting endpoints from parent controller classes, when subclassing controllers.
+  An important feature that was missing so far in the web framework. Example:
+
+```python
+from blacksheep import Application
+from blacksheep.server.controllers import Controller, abstract, get
+
+app = Application()
+
+
+@abstract()
+class BaseController(Controller):
+    @get("/hello-world")
+    def index(self):
+        # Note: the route /hello-world itself will not be registered in the router,
+        # because this class is decorated with @abstract()
+        return self.text(f"Hello, World! {self.__class__.__name__}")
+
+
+class ControllerOne(BaseController):
+    route = "/one"
+
+    # /one/hello-world
+
+
+class ControllerTwo(BaseController):
+    route = "/two"
+
+    # /two/hello-world
+
+    @get("/specific-route")  # /two/specific-route
+    def specific_route(self):
+        return self.text("This is a specific route in ControllerTwo")
+```
+
+- Add a new `@abstract()` decorator that can be applied to controller classes to skip
+  routes defined on them (only inherited classes will have the routes, prefixed by a
+  route).
 - Fix [#498](https://github.com/Neoteroi/BlackSheep/issues/498): Buffer reuse
   and race condition in `client.IncomingContent.stream()`, by @ohait.
 - Fix [#365](https://github.com/Neoteroi/BlackSheep/issues/365), adding support for
diff --git a/blacksheep/server/application.py b/blacksheep/server/application.py
index a358d28..a90a29f 100644
--- a/blacksheep/server/application.py
+++ b/blacksheep/server/application.py
@@ -48,7 +48,7 @@ from blacksheep.server.authorization import (
     handle_forbidden,
     handle_unauthorized,
 )
-from blacksheep.server.bindings import ControllerParameter
+from blacksheep.server.controllers import ControllersManager
 from blacksheep.server.cors import CORSPolicy, CORSStrategy, get_cors_middleware
 from blacksheep.server.env import EnvironmentSettings
 from blacksheep.server.errors import ServerErrorDetailsHandler
@@ -59,7 +59,6 @@ from blacksheep.server.process import use_shutdown_handler
 from blacksheep.server.responses import _ensure_bytes
 from blacksheep.server.routing import (
     MountRegistry,
-    RegisteredRoute,
     RouteMethod,
     Router,
     RoutesRegistry,
@@ -72,7 +71,7 @@ from blacksheep.server.routing import (
 from blacksheep.server.websocket import WebSocket, format_reason
 from blacksheep.sessions import SessionMiddleware, SessionSerializer
 from blacksheep.settings.di import di_settings
-from blacksheep.utils import ensure_bytes, join_fragments
+from blacksheep.utils import join_fragments
 from blacksheep.utils.meta import get_parent_file, import_child_modules
 
 
@@ -213,7 +212,6 @@ class Application(BaseApplication):
         self._session_middleware: Optional[SessionMiddleware] = None
         self.base_path: str = ""  # TODO: deprecate
         self._mount_registry = mount
-
         validate_router(self)
         parent_file = get_parent_file()
 
@@ -564,58 +562,12 @@ class Application(BaseApplication):
         ]
 
     def use_controllers(self):
-        # NB: controller types are collected here, and not with
-        # Controller.__subclasses__(),
-        # to avoid funny bugs in case several Application objects are defined
-        # with different controllers; this is the case for example of tests.
-
-        # This sophisticated approach, using metaclassing, dynamic
-        # attributes, and calling handlers dynamically
-        # with activated instances of controllers; still supports custom
-        # and generic decorators (*args, **kwargs);
-        # as long as `functools.wraps` decorator is used in those decorators.
-        self.register_controllers(self.prepare_controllers())
-
-    def get_controller_handler_pattern(
-        self, controller_type: Type, route: RegisteredRoute
-    ) -> bytes:
         """
-        Returns the full pattern to be used for a route handler,
-        defined as controller method.
+        Configures controllers in the application. This method is called automatically
+        when the application starts.
         """
-        base_route = getattr(controller_type, "route", None)
-
-        if base_route is not None:
-            if callable(base_route):
-                value = base_route()
-            elif isinstance(base_route, (str, bytes)):
-                value = base_route
-            else:
-                raise RuntimeError(
-                    f"Invalid controller `route` attribute. "
-                    f"Controller `{controller_type.__name__}` "
-                    f"has an invalid route attribute: it should "
-                    f"be callable, or str, or bytes."
-                )
-
-            if value:
-                return ensure_bytes(join_fragments(value, route.pattern))
-        return ensure_bytes(route.pattern)
-
-    def prepare_controllers(self) -> List[Type]:
-        controller_types = []
-        for route in self.controllers_router:
-            handler = route.handler
-            controller_type = getattr(handler, "controller_type")
-            controller_types.append(controller_type)
-            handler.__annotations__["self"] = ControllerParameter[controller_type]
-            self.router.add(
-                route.method,
-                self.get_controller_handler_pattern(controller_type, route),
-                handler,
-                controller_type._filters_,
-            )
-        return controller_types
+        controllers_manager = ControllersManager()
+        self.register_controllers(controllers_manager.prepare_controllers(self.router))
 
     def register_controllers(self, controller_types: List[Type]):
         """
diff --git a/blacksheep/server/controllers.py b/blacksheep/server/controllers.py
index 2e3e5cc..3cf1f02 100644
--- a/blacksheep/server/controllers.py
+++ b/blacksheep/server/controllers.py
@@ -5,14 +5,16 @@ from typing import (
     AsyncIterable,
     Callable,
     ClassVar,
+    List,
     Optional,
     Sequence,
     Type,
     Union,
 )
 
-from blacksheep import Request, Response
 from blacksheep.common.types import HeadersType, ParamsType
+from blacksheep.messages import Request, Response
+from blacksheep.server.bindings import ControllerParameter
 from blacksheep.server.responses import (
     ContentDispositionType,
     MessageType,
@@ -38,10 +40,18 @@ from blacksheep.server.responses import (
     view,
     view_async,
 )
-from blacksheep.server.routing import RouteFilter
+from blacksheep.server.routing import (
+    RegisteredRoute,
+    RouteFilter,
+    Router,
+)
 from blacksheep.server.routing import RoutesRegistry as RoutesRegistry  # noqa
-from blacksheep.server.routing import controllers_routes, normalize_filters
-from blacksheep.utils import AnyStr, join_fragments
+from blacksheep.server.routing import (
+    controllers_routes,
+    normalize_filters,
+)
+from blacksheep.utils import AnyStr, ensure_bytes, join_fragments
+from blacksheep.utils.meta import all_subclasses, clonefunc
 
 # singleton router used to store initial configuration,
 # before the application starts
@@ -63,6 +73,62 @@ route = router.route
 ws = router.ws
 
 
+class _BaseControllerRegistry:
+    """
+    Internal class to support skipping the registration of routes defined in base
+    controllers. This is used to support defining routes that should be applied only
+    in subclasses, combining route prefixes of subclasses.
+    """
+
+    _registry = set()
+
+    @classmethod
+    def register(cls, controller):
+        cls._registry.add(controller)
+
+    @classmethod
+    def get_registry(cls):
+        """Returns the registry of all controllers marked as base controllers."""
+        return cls._registry
+
+    @classmethod
+    def is_base_controller(cls, controller):
+        """Returns True if the controller is marked as base controller."""
+        return controller in cls._registry
+
+
+def abstract():
+    """
+    Decorator to mark a controller class as base and indicate that its routes should not
+    be registered directly. If applied to a controller class, its routes will be applied
+    only in subclasses.
+
+    In the following example, only `/one/hello-world` and `/two/hello-world` routes will
+    be applied in the final router (excluding `/hello-world` itself):
+    ```python
+    @abstract()
+    class AppController(Controller):
+        @get("/hello-world")
+        def index(self):
+            return self.text("Hello, World!")
+
+    class ControllerOne(AppController):
+        route = "/one"
+        # /one/hello-world
+
+    class ControllerTwo(AppController):
+        route = "/two"
+        # /two/hello-world
+    ```
+    """
+
+    def class_deco(cls):
+        _BaseControllerRegistry.register(cls)
+        return cls
+
+    return class_deco
+
+
 def filters(
     *filters: RouteFilter,
     host: Optional[str] = None,
@@ -427,3 +493,104 @@ class APIController(Controller):
         if cls_version and cls_name.endswith(cls_version.lower()):
             cls_name = cls_name[: -len(cls_version)]
         return join_fragments("api", cls_version, cls_name)
+
+
+class ControllersManager:
+    """
+    This class is used to apply the routes defined in the controllers, and support
+    inheritance of routes.
+    """
+
+    def prepare_controllers(self, router: Router) -> List[Type]:
+        self._unify_controllers(router.controllers_routes)
+        controller_types = []
+        for route in router.controllers_routes:
+            handler = route.handler
+            controller_type = getattr(handler, "controller_type")
+
+            sub_classes = [
+                sub
+                for sub in all_subclasses(controller_type)
+                if issubclass(sub, Controller)
+            ]
+            for sub in sub_classes:
+                controller_types.append(sub)
+
+            controller_types.append(controller_type)
+
+            handler.__annotations__["self"] = ControllerParameter[controller_type]
+            router.add(
+                route.method,
+                self.get_controller_handler_pattern(controller_type, route),
+                handler,
+                controller_type._filters_,
+            )
+        return controller_types
+
+    def get_controller_handler_pattern(
+        self, controller_type: Type, route: RegisteredRoute
+    ) -> bytes:
+        """
+        Returns the full pattern to be used for a route handler,
+        defined as controller method.
+        """
+        base_route = getattr(controller_type, "route", None)
+
+        if base_route is not None:
+            if callable(base_route):
+                value = base_route()
+            elif isinstance(base_route, (str, bytes)):
+                value = base_route
+            else:
+                raise RuntimeError(
+                    f"Invalid controller `route` attribute. "
+                    f"Controller `{controller_type.__name__}` "
+                    f"has an invalid route attribute: it should "
+                    f"be callable, or str, or bytes."
+                )
+
+            if value:
+                return ensure_bytes(join_fragments(value, route.pattern))
+        return ensure_bytes(route.pattern)
+
+    def _handle_controller_subclasses(self, route, handler, controller_type: Type):
+        # we need to discover subclasses because the user configures requests handlers
+        # on base types and they can be inherited
+        sub_classes = [
+            sub
+            for sub in all_subclasses(controller_type)
+            if issubclass(sub, Controller)
+        ]
+        for sub_class in sub_classes:
+            handler_clone = clonefunc(handler)
+            handler_clone.controller_type = sub_class
+            yield RegisteredRoute(
+                method=route.method,
+                pattern=route.pattern,
+                handler=handler_clone,
+            )
+
+    def _unify_controllers(self, controllers_router):
+        """
+        Unifies the routes of the controllers, to support controllers inheritance.
+        This must happen at application start because at this stage all controller types
+        are loaded in memory and the routes are registered in the router.
+        """
+        routes_to_add = []
+        routes_to_remove = []
+        for route in controllers_router:
+            handler = route.handler
+            controller_type = getattr(handler, "controller_type")
+            # We support skipping the registration of routes defined in base controllers
+            if _BaseControllerRegistry.is_base_controller(controller_type):
+                routes_to_remove.append(route)
+
+            routes_to_add.extend(
+                self._handle_controller_subclasses(route, handler, controller_type)
+            )
+
+        for route in routes_to_add:
+            controllers_router.routes.append(route)
+
+        for route in routes_to_remove:
+            controllers_router.routes.remove(route)
diff --git a/blacksheep/server/routing.py b/blacksheep/server/routing.py
index 99a7cd5..cbf5d49 100644
--- a/blacksheep/server/routing.py
+++ b/blacksheep/server/routing.py
@@ -893,6 +893,9 @@ class RegisteredRoute:
         self.pattern = pattern
         self.handler = handler
 
+    def __repr__(self):
+        return f"<{self.__class__.__name__} {self.method} {self.pattern}>"
+
 
 class RoutesRegistry(RouterBase):
     """
diff --git a/blacksheep/utils/meta.py b/blacksheep/utils/meta.py
index e787f50..5555f88 100644
--- a/blacksheep/utils/meta.py
+++ b/blacksheep/utils/meta.py
@@ -1,3 +1,4 @@
+import copy
 import glob
 import inspect
 import os
@@ -28,3 +29,30 @@ def import_child_modules(root_path: Path):
     stripped_path = os.path.relpath(path).replace("/", ".").replace("\\", ".")
     for module in modules:
         __import__(stripped_path + "." + module)
+
+
+def clonefunc(func):
+    """
+    Clone a function, preserving its name and docstring.
+    """
+    new_func = func.__class__(
+        func.__code__,
+        func.__globals__,
+        func.__name__,
+        func.__defaults__,
+        func.__closure__,
+    )
+    new_func.__doc__ = func.__doc__
+    new_func.__dict__ = copy.deepcopy(func.__dict__)
+    return new_func
+
+
+def all_subclasses(cls):
+    """
+    Return all subclasses of a class, including those defined in other modules.
+    """
+    subclasses = set()
+    for subclass in cls.__subclasses__():
+        subclasses.add(subclass)
+        subclasses.update(all_subclasses(subclass))
+    return subclasses
diff --git a/example.py b/example.py
new file mode 100644
index 0000000..d145b60
--- /dev/null
+++ b/example.py
@@ -0,0 +1,59 @@
+from blacksheep import Application
+from blacksheep.server.controllers import Controller, abstract, get
+
+app = Application()
+
+
+@abstract()
+class BaseController(Controller):
+    @get("/hello-world")
+    def index(self):
+        # Note: the route /hello-world itself will not be registered in the router,
+        # because this class is decorated with @abstract()
+        print(self)
+        return self.text("Hello, World!")
+
+
+class ControllerOne(BaseController):
+    route = "/one"
+
+    # /one/hello-world
+
+
+class ControllerTwo(BaseController):
+    route = "/two"
+
+    # /two/hello-world
+
+    @get("/specific-route")  # /two/specific-route
+    def specific_route(self):
+        print(self)
+        assert isinstance(self, ControllerTwo)
+        return self.text("This is a specific route in ControllerTwo")
+
+
+class ControllerTwoBis(ControllerTwo):
+    route = "/two-bis"
+
+    # /two-bis/hello-world
+
+    # /two-bis/specific-route
+
+    @get("/specific-route-2")  # /two-bis/specific-route-2
+    def specific_route(self):
+        print(self)
+        assert isinstance(self, ControllerTwoBis)
+        return self.text("This is a specific route in ControllerTwoBis")
+
+
+@app.after_start
+async def after_start():
+    if len(app.router.routes[b"GET"]) != 6:
+        print("Routes not registered correctly")
+    print(app.router.routes)
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run(app, port=44777)


Test patch:
diff --git a/tests/test_controllers.py b/tests/test_controllers.py
index 21cada2..5a9c549 100644
--- a/tests/test_controllers.py
+++ b/tests/test_controllers.py
@@ -13,6 +13,7 @@ from blacksheep.server.controllers import (
     APIController,
     Controller,
     RoutesRegistry,
+    abstract,
     filters,
 )
 from blacksheep.server.di import register_http_context
@@ -1021,3 +1022,66 @@ async def test_controller_pydantic_validate_call_scenario(app):
             assert response.status == status
             if int(PYDANTIC_LIB_VERSION[0]) > 1:
                 assert response_text in (await response.text())
+
+
+async def test_controllers_inheritance(app):
+    get = app.router.controllers_routes.get
+
+    @abstract()
+    class BaseController(Controller):
+        @get("/hello-world")
+        def index(self):
+            # Note: the route /hello-world itself will not be registered in the router,
+            # because this class is decorated with @abstract()
+            return self.text(f"Hello, World! {self.__class__.__name__}")
+
+    class ControllerOne(BaseController):
+        route = "/one"
+
+        # /one/hello-world
+
+    class ControllerTwo(BaseController):
+        route = "/two"
+
+        # /two/hello-world
+
+        @get("/specific-route")  # /two/specific-route
+        def specific_route(self):
+            return self.text(f"This is a specific route in {self.__class__.__name__}")
+
+    class ControllerTwoBis(ControllerTwo):
+        route = "/two-bis"
+
+        # /two-bis/hello-world
+
+        # /two-bis/specific-route
+
+        @get("/specific-route-2")  # /two-bis/specific-route-2
+        def specific_route(self):
+            return self.text(f"This is another route in {self.__class__.__name__}")
+
+    await app.start()
+
+    routes = app.router.routes[b"GET"]
+    assert len(routes) == 6
+
+    expected_endpoints = [
+        ("/one/hello-world", "Hello, World! ControllerOne"),
+        ("/two/hello-world", "Hello, World! ControllerTwo"),
+        ("/two-bis/hello-world", "Hello, World! ControllerTwoBis"),
+        ("/two/specific-route", "This is a specific route in ControllerTwo"),
+        ("/two-bis/specific-route", "This is a specific route in ControllerTwoBis"),
+        ("/two-bis/specific-route-2", "This is another route in ControllerTwoBis"),
+    ]
+
+    for endpoint, result in expected_endpoints:
+        await app(
+            get_example_scope("GET", endpoint),
+            MockReceive(),
+            MockSend(),
+        )
+        response = app.response
+        assert response is not None
+        assert response.status == 200
+        text = await response.text()
+        assert text == result