Repository: sanic-org/sanic
Pull request: Add route context
This PR implements the idea discussed in #2300. It is an initial PR meant to explore the concept and idea of passing value to the route context using the suggestion from @Tronic to use a `ctx_` prefix in the route kwargs.

```python
@app.route("/", ctx_foo=123)
async def handler(request: Request):
```

Closes #2300 

Problem statement:
Labeling / tagging routes
**Is your feature request related to a problem? Please describe.**
Some of my routes requires authentication and some of them don't (such as home page). I'm currently using a middleware and hard-code URLs that requires authentication, however it would be better to have a property on routes when registering them.

```python
@bp.on_request
async def before_request(request : Request):
    if request.route and request.route.uri in [
        "/authorize",
        "/profile/<user:str>"
    ]:
        request.ctx.token = decrypt_token(request.cookies.get("access_token"))
        # If user is not signed in, redirect user to OAuth.
        if (request.route.uri != "/authorize") and (not request.ctx.token):
            return redirect(oauth_url("ID", "/oauth_callback", request.path))
    # No need to get token for other endpoints becase other pages
    # doesn't require auth and decrypt_token() takes time.

@bp.get("/authorize")
async def authorize(request : Request):
    ...

@bp.get("/profile/<user:str>")
async def profile(request : Request, user : str):
    ...

@bp.get("/")
async def home(request : Request):
    ...
```


**Describe the solution you'd like**
It can be much easier (and may look cleaner) if we have `tags` or `labels` parameter when registering the routes. (It can accept a list of enums or strings or anything). So we can access these tags in middlewares and other places. I know I can also use a decorator and inject tags to routes, but I prefer having an in-built property instead of monkey-patching the routes. 

```python
class Tags(Enum):
    BASE_AUTH = "base_auth"
    REQUIRES_AUTH = "requires_auth"

@bp.on_request
async def before_request(request : Request):
    if Tags.REQUIRES_AUTH in request.route.tags:
        request.ctx.token = decrypt_token(request.cookies.get("access_token"))
        # If user is not signed in, redirect user to OAuth.
        if (Tags.BASE_AUTH in request.route.tags) and (not request.ctx.token):
            return redirect(oauth_url("ID", "/oauth_callback", request.path))
    # No need to get token for other endpoints becase other pages
    # doesn't require auth and decrypt_token() takes time.

@bp.get("/authorize", tags = [Tags.REQUIRES_AUTH, Tags.BASE_AUTH])
async def authorize(request : Request):
    ...

@bp.get("/profile/<user:str>", tags = [Tags.REQUIRES_AUTH])
async def profile(request : Request, user : str):
    ...

@bp.get("/")
async def home(request : Request):
    ...
```


**Additional context**
N/A

Hints from issue discussion:
The routing decorators need no more parameters, that gets convoluted quite quickly. Instead I would recommend stacking more decorators. Something like:

```python
def requires_auth(handler):
    handler.requires_auth = True   # Check for this flag in your middleware
    return handler

@requires_auth
@bp.get('/secret')
def handler(req):
    ...
```

Problems:
1. it might depend on the order of decorators (if some of them do not return the same object back but create another)
2. I do not know off hand how to access the handler object in the middleware context (possibly it is easy, check the docs)

Alternatively, you could make a middleware that simply does the auth check before calling the original handler. This avoids the use of a middleware entirely, and would most likely need to go between the route decorator and the handler to be effective.
I'm not sure I agree. Labels/tags seem like they could be helpful. Generally in the past I've used blueprints for this. And then nested then with groups. The problem that solution provides is that you are limited by hierarchical structures and it makes something difficult to belong in multiple groups.

A primary goal of Sanic is flexibility, and the Middleware pattern is legit. This feature helps that, so I am not so quick to discount it.

One of the features added this past year is that routes now have a `ctx`. Perhaps rather than specifically limiting the feature to "tags", we introduce a definition-time API to inject arbitrary data into the route context.
> One of the features added this past year is that routes now have a `ctx`.

Oh, I only knew request objects has `ctx` but I didn't know routes has `ctx` too. Thanks, this is even better than tags! I haven't seen that in docs (or I missed it - sorry).

So, to make it clear, I need to create a new decorator, then set `ctx` on routes.
> Oh, I only knew request objects has `ctx` but I didn't know routes has `ctx` too. Thanks, this is even better than tags! I haven't seen that in docs (or I missed it - sorry).

This probably is an undocumented feature. :grimacing: In which case I am sorry. It exists mainly for the benefit of plugin developers, and I think it is the _only_ instance where Sanic actually injects data into one of these objects.

> So, to make it clear, I need to create a new decorator, then set `ctx` on routes.

It would be nice if that were the case, but I am not so sure it would be that easy. I think you would need to do something a little more "hands on". You could do something like this:

```python
def labelled_route(app: Sanic, *args, labels: List[str], **kwargs):
    def decorator(f):
        starting = app._future_routes
        app.route(*args, **kwargs, apply=False)(f)
        diff = starting | app._future_routes
        future_route = list(diff)[0]
        route_list = app._apply_route(future_route)
        for route in route_list:
            route.ctx.labels = labels

        return f

    return decorator


@labelled_route(app, "/", labels=["apple"])
async def handler(request: Request):
    return json(request.route.ctx.labels)
```

The problem is that this would not work so well with Blueprints because blueprint routes are not applied when they are declared, but instead when you register them with `app.blueprint`.

---

What I am suggesting is that we implement something like this as an API:

```python
@app.get("/", context={"labels": ["apple"], "foo": 123})
async def handler(request: Request):
    assert request.route.ctx.labels == ["apple"]
    assert request.route.ctx.foo == 123
```

Since `route.ctx` is the only place where it is not safe to put arbitrary information into the `ctx` object (there is a whole different discussion about why that is), we would need to put more thought than just the above into it. But it totally makes sense to me, especially since it is not so easy otherwise to get access to the `route` object at definition.
> This probably is an undocumented feature. 😬 In which case I am sorry. It exists mainly for the benefit of plugin developers, and I think it is the only instance where Sanic actually injects data into one of these objects.

No problem, at least I learned about the feature. 😄

> What I am suggesting is that we implement something like this as an API:
>
> ```python
> @app.get("/", context={"labels": ["apple"], "foo": 123})
> async def handler(request: Request):
>     assert request.route.ctx.labels == ["apple"]
>     assert request.route.ctx.foo == 123
> ```

Yeah, this is much cleaner and simpler than making a decorator. However, until then, I will use the decorator, thanks for help!
I might prefer something like `ctx_labels=["apple"], ctx_foo=123` instead of using dict for that, first for better syntax and secondly because ctx attributes are not dict keys. But such hackery with kwargs' names is a little evil too.

I also thought about maybe making it a callable that extends the object. But I think that makes it more Complicated rather than less. The prefixed arguments is not terrible. It's biggest flaw IMO is that it's not immediately obvious by inspecting the signature.

Commit message: Add route context

Patch to review:
diff --git a/sanic/app.py b/sanic/app.py
index baee112e20..1018c1daed 100644
--- a/sanic/app.py
+++ b/sanic/app.py
@@ -384,12 +384,16 @@ def _apply_route(self, route: FutureRoute) -> List[Route]:
             websocket_handler.is_websocket = True  # type: ignore
             params["handler"] = websocket_handler
 
+        ctx = params.pop("route_context")
+
         routes = self.router.add(**params)
         if isinstance(routes, Route):
             routes = [routes]
+
         for r in routes:
             r.ctx.websocket = websocket
             r.ctx.static = params.get("static", False)
+            r.ctx.__dict__.update(ctx)
 
         return routes
 
diff --git a/sanic/blueprints.py b/sanic/blueprints.py
index e5e1d33327..95a64cd283 100644
--- a/sanic/blueprints.py
+++ b/sanic/blueprints.py
@@ -326,6 +326,7 @@ def register(self, app, options):
                 future.static,
                 version_prefix,
                 error_format,
+                future.route_context,
             )
 
             route = app._apply_route(apply_route)
diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py
index 8467a2e340..c880f35f3e 100644
--- a/sanic/mixins/routes.py
+++ b/sanic/mixins/routes.py
@@ -26,12 +26,21 @@
 from sanic.models.futures import FutureRoute, FutureStatic
 from sanic.models.handler_types import RouteHandler
 from sanic.response import HTTPResponse, file, file_stream
+from sanic.types import HashableDict
 from sanic.views import CompositionView
 
 
 RouteWrapper = Callable[
     [RouteHandler], Union[RouteHandler, Tuple[Route, RouteHandler]]
 ]
+RESTRICTED_ROUTE_CONTEXT = (
+    "ignore_body",
+    "stream",
+    "hosts",
+    "static",
+    "error_format",
+    "websocket",
+)
 
 
 class RouteMixin:
@@ -65,6 +74,7 @@ def route(
         static: bool = False,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **kwargs,
     ) -> RouteWrapper:
         """
         Decorate a function to be registered as a route
@@ -94,6 +104,8 @@ def route(
         if not methods and not websocket:
             methods = frozenset({"GET"})
 
+        route_context = self._build_route_context(kwargs)
+
         def decorator(handler):
             nonlocal uri
             nonlocal methods
@@ -152,6 +164,7 @@ def decorator(handler):
                 static,
                 version_prefix,
                 error_format,
+                route_context,
             )
 
             self._future_routes.add(route)
@@ -196,6 +209,7 @@ def add_route(
         stream: bool = False,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **kwargs,
     ) -> RouteHandler:
         """A helper method to register class instance or
         functions as a handler to the application url
@@ -247,6 +261,7 @@ def add_route(
             name=name,
             version_prefix=version_prefix,
             error_format=error_format,
+            **kwargs,
         )(handler)
         return handler
 
@@ -261,6 +276,7 @@ def get(
         ignore_body: bool = True,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **kwargs,
     ) -> RouteWrapper:
         """
         Add an API URL under the **GET** *HTTP* method
@@ -285,6 +301,7 @@ def get(
             ignore_body=ignore_body,
             version_prefix=version_prefix,
             error_format=error_format,
+            **kwargs,
         )
 
     def post(
@@ -297,6 +314,7 @@ def post(
         name: Optional[str] = None,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **kwargs,
     ) -> RouteWrapper:
         """
         Add an API URL under the **POST** *HTTP* method
@@ -321,6 +339,7 @@ def post(
             name=name,
             version_prefix=version_prefix,
             error_format=error_format,
+            **kwargs,
         )
 
     def put(
@@ -333,6 +352,7 @@ def put(
         name: Optional[str] = None,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **kwargs,
     ) -> RouteWrapper:
         """
         Add an API URL under the **PUT** *HTTP* method
@@ -357,6 +377,7 @@ def put(
             name=name,
             version_prefix=version_prefix,
             error_format=error_format,
+            **kwargs,
         )
 
     def head(
@@ -369,6 +390,7 @@ def head(
         ignore_body: bool = True,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **kwargs,
     ) -> RouteWrapper:
         """
         Add an API URL under the **HEAD** *HTTP* method
@@ -401,6 +423,7 @@ def head(
             ignore_body=ignore_body,
             version_prefix=version_prefix,
             error_format=error_format,
+            **kwargs,
         )
 
     def options(
@@ -413,6 +436,7 @@ def options(
         ignore_body: bool = True,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **kwargs,
     ) -> RouteWrapper:
         """
         Add an API URL under the **OPTIONS** *HTTP* method
@@ -445,6 +469,7 @@ def options(
             ignore_body=ignore_body,
             version_prefix=version_prefix,
             error_format=error_format,
+            **kwargs,
         )
 
     def patch(
@@ -457,6 +482,7 @@ def patch(
         name: Optional[str] = None,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **kwargs,
     ) -> RouteWrapper:
         """
         Add an API URL under the **PATCH** *HTTP* method
@@ -491,6 +517,7 @@ def patch(
             name=name,
             version_prefix=version_prefix,
             error_format=error_format,
+            **kwargs,
         )
 
     def delete(
@@ -503,6 +530,7 @@ def delete(
         ignore_body: bool = True,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **kwargs,
     ) -> RouteWrapper:
         """
         Add an API URL under the **DELETE** *HTTP* method
@@ -527,6 +555,7 @@ def delete(
             ignore_body=ignore_body,
             version_prefix=version_prefix,
             error_format=error_format,
+            **kwargs,
         )
 
     def websocket(
@@ -540,6 +569,7 @@ def websocket(
         apply: bool = True,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **kwargs,
     ):
         """
         Decorate a function to be registered as a websocket route
@@ -567,6 +597,7 @@ def websocket(
             websocket=True,
             version_prefix=version_prefix,
             error_format=error_format,
+            **kwargs,
         )
 
     def add_websocket_route(
@@ -580,6 +611,7 @@ def add_websocket_route(
         name: Optional[str] = None,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **kwargs,
     ):
         """
         A helper method to register a function as a websocket route.
@@ -609,6 +641,7 @@ def add_websocket_route(
             name=name,
             version_prefix=version_prefix,
             error_format=error_format,
+            **kwargs,
         )(handler)
 
     def static(
@@ -957,3 +990,18 @@ def visit_Return(self, node: Return) -> Any:
         HttpResponseVisitor().visit(node)
 
         return types
+
+    def _build_route_context(self, raw):
+        ctx_kwargs = {
+            key.replace("ctx_", ""): value
+            for key, value in raw.items()
+            if key.startswith("ctx_")
+        }
+        restricted = [
+            key for key in ctx_kwargs.keys() if key in RESTRICTED_ROUTE_CONTEXT
+        ]
+        if restricted:
+            raise AttributeError(
+                f"Cannot use restricted route context: {restricted}."
+            )
+        return HashableDict(ctx_kwargs)
diff --git a/sanic/mixins/signals.py b/sanic/mixins/signals.py
index 2be9fee2e6..4032475ca0 100644
--- a/sanic/mixins/signals.py
+++ b/sanic/mixins/signals.py
@@ -3,11 +3,7 @@
 from sanic.models.futures import FutureSignal
 from sanic.models.handler_types import SignalHandler
 from sanic.signals import Signal
-
-
-class HashableDict(dict):
-    def __hash__(self):
-        return hash(tuple(sorted(self.items())))
+from sanic.types import HashableDict
 
 
 class SignalMixin:
diff --git a/sanic/models/futures.py b/sanic/models/futures.py
index fe7d77ebcc..97929ef138 100644
--- a/sanic/models/futures.py
+++ b/sanic/models/futures.py
@@ -7,6 +7,7 @@
     MiddlewareType,
     SignalHandler,
 )
+from sanic.types import HashableDict
 
 
 class FutureRoute(NamedTuple):
@@ -25,6 +26,7 @@ class FutureRoute(NamedTuple):
     static: bool
     version_prefix: str
     error_format: Optional[str]
+    route_context: HashableDict
 
 
 class FutureListener(NamedTuple):
diff --git a/sanic/types/__init__.py b/sanic/types/__init__.py
new file mode 100644
index 0000000000..043fffb456
--- /dev/null
+++ b/sanic/types/__init__.py
@@ -0,0 +1,4 @@
+from .hashable_dict import HashableDict
+
+
+__all__ = ("HashableDict",)
diff --git a/sanic/types/hashable_dict.py b/sanic/types/hashable_dict.py
new file mode 100644
index 0000000000..e756a3219c
--- /dev/null
+++ b/sanic/types/hashable_dict.py
@@ -0,0 +1,3 @@
+class HashableDict(dict):
+    def __hash__(self):
+        return hash(tuple(sorted(self.items())))


Code review comments:
diff_hunk: @@ -65,6 +74,7 @@ def route(
         static: bool = False,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **kwargs,
path: sanic/mixins/routes.py
text: @user1:
Perhaps also name this `ctx_kwargs` if no other kwargs are accepted. IDEs show the name while typing in code.