diff --git a/blacksheep/docs/authentication.md b/blacksheep/docs/authentication.md index 956e77c..6fd50db 100644 --- a/blacksheep/docs/authentication.md +++ b/blacksheep/docs/authentication.md @@ -171,7 +171,7 @@ the future. /// admonition | 💡 -It is possible to configure several JWTBearerAuthentication handlers, +It is possible to configure several `JWTBearerAuthentication` handlers, for applications that need to support more than one identity provider. For example, for applications that need to support sign-in through Auth0, Azure Active Directory, Azure Active Directory B2C. @@ -309,6 +309,64 @@ The example below shows how a user's identity can be read from the web request: # handler) ``` +## Dependency Injection in authentication handlers + +Dependency Injection is supported in authentication handlers. To use it: + +1. Configure `AuthenticationHandler` objects as types (not instances) + associated to the `AuthenticationStrategy` object. +2. Register dependencies in the DI container, and in the handler classes + according to the solution you are using for dependency injection. + +The code below illustrates and example using the built-in solution for DI. + +```python {linenums="1" hl_lines="7-8 11-13 23-26 28-30"} +from blacksheep import Application, Request, json +from guardpost import AuthenticationHandler, Identity + +app = Application() + + +class ExampleDependency: + pass + + +class MyAuthenticationHandler(AuthenticationHandler): + def __init__(self, dependency: ExampleDependency) -> None: + self.dependency = dependency + + def authenticate(self, context: Request) -> Identity | None: + # TODO: implement your own authentication logic + assert isinstance(self.dependency, ExampleDependency) + return Identity({"id": "example", "sub": "001"}, self.scheme) + + +auth = app.use_authentication() # AuthenticationStrategy + +# The authentication handler will be instantiated by `app.services`, +# which can be any object implementing the ContainerProtocol +auth.add(MyAuthenticationHandler) + +# We need to register the types in the DI container! +app.services.register(MyAuthenticationHandler) +app.services.register(ExampleDependency) + + +@app.router.get("/") +def home(request: Request): + assert request.user is not None + return json(request.user.claims) +``` + +/// admonition | ContainerProtocol. + type: tip + +As documented in [_Container Protocol_](./dependency-injection.md#the-container-protocol), BlackSheep +supports the use of other DI containers as replacements for the built-in +library used for dependency injection. + +/// + ## Next While authentication focuses on *identifying* users, authorization determines diff --git a/blacksheep/docs/authorization.md b/blacksheep/docs/authorization.md index 0d2927c..a994568 100644 --- a/blacksheep/docs/authorization.md +++ b/blacksheep/docs/authorization.md @@ -270,3 +270,80 @@ returns: - Status [`403 Forbidden`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403) if authentication succeeded as valid credentials were provided, but the user is not authorized to perform an action. + + +## Dependency Injection in authorization requirements + +Dependency Injection is supported in authorization code. To use it: + +1. Configure `Requirement` objects as types (not instances) + associated to the policies of the `AuthorizationStrategy` object. +2. Register dependencies in the DI container, and in the handler classes + according to the solution you are using for dependency injection. + +The code below illustrates and example using the built-in solution for DI. + +```python {linenums="1" hl_lines="13-14 17-18 21 41-42 45-46"} +from blacksheep import Application, Request, json +from guardpost import ( + AuthenticationHandler, + AuthorizationContext, + Identity, + Policy, + Requirement, +) + +app = Application(show_error_details=True) + + +class ExampleDependency: + pass + + +class MyInjectedRequirement(Requirement): + dependency: ExampleDependency + + def handle(self, context: AuthorizationContext): # Note: this can also be async! + assert isinstance(self.dependency, ExampleDependency) + # + # TODO: implement here the authorization logic + # + roles = context.identity.claims.get("roles", []) + if roles and "ADMIN" in roles: + context.succeed(self) + else: + context.fail("The user is not an ADMIN") + + +class MyAuthenticationHandler(AuthenticationHandler): + def authenticate(self, context: Request) -> Identity | None: + # TODO: implement your own authentication logic + return Identity({"id": "example", "sub": "001", "roles": []}, self.scheme) + + +authentication = app.use_authentication() +authentication.add(MyAuthenticationHandler) + +authorization = app.use_authorization() +authorization.with_default_policy(Policy("default", MyInjectedRequirement)) + +# We need to register the types in the DI container! +app.services.register(MyInjectedRequirement) +app.services.register(ExampleDependency) +app.services.register(MyAuthenticationHandler) + + +@app.router.get("/") +def home(request: Request): + assert request.user is not None + return json(request.user.claims) +``` + +/// admonition | ContainerProtocol. + type: tip + +As documented in [_Container Protocol_](./dependency-injection.md#the-container-protocol), BlackSheep +supports the use of other DI containers as replacements for the built-in +library used for dependency injection. + +/// diff --git a/blacksheep/docs/controllers.md b/blacksheep/docs/controllers.md index 5e96559..85a87da 100644 --- a/blacksheep/docs/controllers.md +++ b/blacksheep/docs/controllers.md @@ -10,15 +10,18 @@ This page describes: - [X] Controller methods. - [X] API Controllers. +- [X] Controllers inheritance. It is recommended to follow the [MVC tutorial](mvc-project-template.md) before reading this page. -/// admonition | For Flask users +/// admonition | For Flask users. type: tip + If you come from Flask, controllers in BlackSheep can be considered equivalent to Flask's Blueprints, as they allow to group request handlers in dedicated modules and classes. + /// ## The Controller class @@ -98,6 +101,9 @@ The following example shows how dependency injection can be used in controller constructors, and an implementation of the `on_request` method: ```python +from blacksheep import Application +from blacksheep.server.controllers import Controller, get + app = Application() @@ -108,11 +114,12 @@ class Settings: self.greetings = greetings +app.services.add_instance(Settings(value)) + + class Home(Controller): - def __init__(self, settings: Settings): - # controllers are instantiated dynamically at every web request - self.settings = settings + settings: Settings async def on_request(self, request: Request): print("[*] Received a request!!") @@ -123,13 +130,6 @@ class Home(Controller): @get("/") async def index(self, request: Request): return text(self.greet()) - -# when configuring the application, register -# a singleton of the application settings, -# this service is automatically injected into request handlers -# having a signature parameter type annotated `: Settings`, or -# having name "settings", without type annotations -app.services.add_instance(Settings(value)) ``` The dependency can also be described as class property: @@ -146,7 +146,7 @@ If route methods (e.g. `head`, `get`, `post`, `put`, `patch`) from instance for controllers is used. It is also possible to use a specific router, as long as this router is bound to the application object: -```py +```python from blacksheep.server.routing import RoutesRegistry @@ -155,6 +155,35 @@ app.controllers_router = RoutesRegistry() get = app.controllers_router.get ``` +### route classmethod + +The `route` `classmethod` can be used to define base routes for all request +handlers defined on a Controller class. In the following example, the actual +routes become: `/home` and `/home/about`. + +```python +from blacksheep import Application +from blacksheep.server.controllers import Controller, get + + +app = Application() + + +class Home(Controller): + + @classmethod + def route(cls): + return "/home/" + + @get("/") + def home(self): + return self.ok({"message": "Hello!"}) + + @get("/about") + def about(self): + return self.ok({"message": "About..."}) +``` + ## The APIController class The `APIController` class is a kind of `Controller` dedicated to API @@ -241,3 +270,120 @@ class Cats(APIController): ... ``` + +## Controllers inheritance + +Since version `2.3.0`, the framework supports routes inheritance in controllers. +Consider the following example: + +```python {linenums="1" hl_lines="8-9 12-13 15 21-21"} +from blacksheep import Application +from blacksheep.server.controllers import Controller, get + + +app = Application() + + +class BaseController(Controller): + path: ClassVar[str] = "base" + + @classmethod + def route(cls) -> Optional[str]: + return f"/api/{cls.path}" + + @get("/foo") # /api/base/foo + def foo(self): + return self.ok(self.__class__.__name__) + + +class Derived(BaseController): + path = "derived" + + # /api/derived/foo (inherited from the base class) +``` + +In the example above, the following routes are configured: + +- `/api/base/foo`, defined in `BaseController` +- `/api/derived/foo`, defined in `Derived` + +To exclude the routes registered in a base controller class, decorate the class +using the `@abstract()` decorator imported from `blacksheep.server.controllers`. + +```python +from blacksheep.server.controllers import Controller, abstract, get + + +@abstract() +class BaseController(Controller): + @get("/hello-world") +``` + +The following example illustrates a scenario in which a base class defines a +`/hello-world` route, inherited in sub-classes that each apply a different +base route. The `ControllerTwo` class defines one more route, which is also +inherited by `ControllerTwoBis`; and this last class defines one more specific +route. + +```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): + @classmethod + def route(cls) -> str: + return "/one" + + # /one/hello-world + + +class ControllerTwo(BaseController): + @classmethod + def route(cls) -> str: + return "/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): + @classmethod + def route(cls) -> str: + return "/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__}") +``` + +All routes of this example, with their respective response texts, are: + +- `/one/hello-world` :material-arrow-right: "Hello, World! ControllerOne" +- `/two/hello-world` :material-arrow-right: "Hello, World! ControllerTwo" +- `/two-bis/hello-world` :material-arrow-right: "Hello, World! ControllerTwoBis" +- `/two/specific-route` :material-arrow-right: "This is a specific route in ControllerTwo" +- `/two-bis/specific-route` :material-arrow-right: "This is a specific route in ControllerTwoBis" +- `/two-bis/specific-route-2` :material-arrow-right: "This is another route in ControllerTwoBis" + +Controller types and their dependencies are resolved appropriately for each +request handler, diff --git a/blacksheep/docs/routing.md b/blacksheep/docs/routing.md index 6b2b1b4..c986d84 100644 --- a/blacksheep/docs/routing.md +++ b/blacksheep/docs/routing.md @@ -464,7 +464,7 @@ app = Application(router=router) ### Controllers dedicated router -Controllers need a different kind of router, an instance of +Controllers uses a different kind of router, an instance of `blacksheep.server.routing.RoutesRegistry`. If using a dedicated router for controllers is desired, do this instead: @@ -509,11 +509,20 @@ app = Application() app.controllers_router = controllers_router ``` -/// admonition | About Router and RoutesRegistry +/// admonition | About Router and RoutesRegistry. + type: warning Controller routes use a "RoutesRegistry" to support the dynamic generation of paths by controller class name. Controller routes are evaluated and merged into `Application.router` when the application starts. +Since version `2.3.0`, all routes in BlackSheep behave this way and decorators +in `app.router` and `app.router.controllers_routes` can be used +interchangeably. +Before version `2.3.0`, it is _necessary_ to use the correct methods when +defining request handlers: the decorators of the `router.controllers_routes` +for controllers' methods, and the decorators of the `router` for request +handlers defined using functions. + /// ## Routing prefix @@ -531,3 +540,93 @@ To globally configure a prefix for all routes, use the environment variable This feature is intended for applications deployed behind proxies. For more information, refer to [_Behind proxies_](./behind-proxies.md). + +## How to track routes that matched a request + +BlackSheep by default does not track which _route_ matched a web request, +because this is not always necessary. However, for logging purposes it can be +useful to log the route pattern instead of the exact request URL, to reduce +logs cardinality. + +One option to keep track of the route that matches a request is to wrap the +`get_match` of the Application's router: + +```python + def wrap_get_route_match( + fn: Callable[[Request], Optional[RouteMatch]] + ) -> Callable[[Request], Optional[RouteMatch]]: + @wraps(fn) + def get_route_match(request: Request) -> Optional[RouteMatch]: + match = fn(request) + request.route = match.pattern.decode() if match else "Not Found" # type: ignore + return match + + return get_route_match + + app.router.get_match = wrap_get_route_match(app.router.get_match) # type: ignore +``` + +If monkey-patching methods in Python looks ugly, a specific `Router` class can +be used, like in the following example: + +```python +from blacksheep import Application, Router +from blacksheep.messages import Request +from blacksheep.server.routing import RouteMatch + + +class TrackingRouter(Router): + + def get_match(self, request: Request) -> RouteMatch | None: + match = super().get_match(request) + request.route = match.pattern.decode() if match else "Not Found" # type: ignore + return match + + +app = Application(router=TrackingRouter()) + + +@app.router.get("/*") +def home(request): + return ( + f"Request path: {request.url.path.decode()}\n" + + f"Request route path: {request.route}\n" + ) +``` + +If attaching additional properties to the request object also looks suboptimal, +a `WeakKeyDictionary` can be used to store additional information about the +request object, like in this example: + +```python +import weakref + +from blacksheep import Application, Router +from blacksheep.messages import Request +from blacksheep.server.routing import RouteMatch + + +class TrackingRouter(Router): + + def __init__(self): + super().__init__() + self.requests_routes = weakref.WeakKeyDictionary() + + def get_match(self, request: Request) -> RouteMatch | None: + match = super().get_match(request) + self.requests_routes[request] = match.pattern.decode() if match else "Not Found" + return match + + +router = TrackingRouter() + +app = Application(router=router) + + +@app.router.get("/*") +def home(request): + return ( + f"Request path: {request.url.path.decode()}\n" + + f"Request route path: {router.requests_routes[request]}\n" + ) +```