Repository: sanic-org/sanic (python)

Issue:
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


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 

Patch:
diff --git a/sanic/app.py b/sanic/app.py
index 7c740383..751d1800 100644
--- a/sanic/app.py
+++ b/sanic/app.py
@@ -382,12 +382,16 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
             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 6a6c2e82..98308bc4 100644
--- a/sanic/blueprints.py
+++ b/sanic/blueprints.py
@@ -348,6 +348,7 @@ class Blueprint(BaseSanic):
                 future.static,
                 version_prefix,
                 error_format,
+                future.route_context,
             )
 
             if (self, apply_route) in app._future_registry:
diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py
index 01911e66..bfadd2df 100644
--- a/sanic/mixins/routes.py
+++ b/sanic/mixins/routes.py
@@ -26,12 +26,21 @@ from sanic.log import error_logger
 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,10 +74,20 @@ class RouteMixin:
         static: bool = False,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **ctx_kwargs: Any,
     ) -> RouteWrapper:
         """
         Decorate a function to be registered as a route
 
+
+        **Example using context kwargs**
+
+        .. code-block:: python
+
+            @app.route(..., ctx_foo="foobar")
+            async def route_handler(request: Request):
+                assert request.route.ctx.foo == "foobar"
+
         :param uri: path of the URL
         :param methods: list or tuple of methods allowed
         :param host: the host, if required
@@ -80,6 +99,8 @@ class RouteMixin:
             body (eg. GET requests)
         :param version_prefix: URL path that should be before the version
             value; default: ``/v``
+        :param  ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
+            will be appended to the route context (``route.ctx``)
         :return: tuple of routes, decorated function
         """
 
@@ -94,6 +115,8 @@ class RouteMixin:
         if not methods and not websocket:
             methods = frozenset({"GET"})
 
+        route_context = self._build_route_context(ctx_kwargs)
+
         def decorator(handler):
             nonlocal uri
             nonlocal methods
@@ -152,6 +175,7 @@ class RouteMixin:
                 static,
                 version_prefix,
                 error_format,
+                route_context,
             )
 
             self._future_routes.add(route)
@@ -196,6 +220,7 @@ class RouteMixin:
         stream: bool = False,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **ctx_kwargs,
     ) -> RouteHandler:
         """A helper method to register class instance or
         functions as a handler to the application url
@@ -212,6 +237,8 @@ class RouteMixin:
         :param stream: boolean specifying if the handler is a stream handler
         :param version_prefix: URL path that should be before the version
             value; default: ``/v``
+        :param  ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
+            will be appended to the route context (``route.ctx``)
         :return: function or class instance
         """
         # Handle HTTPMethodView differently
@@ -247,6 +274,7 @@ class RouteMixin:
             name=name,
             version_prefix=version_prefix,
             error_format=error_format,
+            **ctx_kwargs,
         )(handler)
         return handler
 
@@ -261,6 +289,7 @@ class RouteMixin:
         ignore_body: bool = True,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **ctx_kwargs,
     ) -> RouteWrapper:
         """
         Add an API URL under the **GET** *HTTP* method
@@ -273,6 +302,8 @@ class RouteMixin:
         :param name: Unique name that can be used to identify the Route
         :param version_prefix: URL path that should be before the version
             value; default: ``/v``
+        :param  ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
+            will be appended to the route context (``route.ctx``)
         :return: Object decorated with :func:`route` method
         """
         return self.route(
@@ -285,6 +316,7 @@ class RouteMixin:
             ignore_body=ignore_body,
             version_prefix=version_prefix,
             error_format=error_format,
+            **ctx_kwargs,
         )
 
     def post(
@@ -297,6 +329,7 @@ class RouteMixin:
         name: Optional[str] = None,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **ctx_kwargs,
     ) -> RouteWrapper:
         """
         Add an API URL under the **POST** *HTTP* method
@@ -309,6 +342,8 @@ class RouteMixin:
         :param name: Unique name that can be used to identify the Route
         :param version_prefix: URL path that should be before the version
             value; default: ``/v``
+        :param  ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
+            will be appended to the route context (``route.ctx``)
         :return: Object decorated with :func:`route` method
         """
         return self.route(
@@ -321,6 +356,7 @@ class RouteMixin:
             name=name,
             version_prefix=version_prefix,
             error_format=error_format,
+            **ctx_kwargs,
         )
 
     def put(
@@ -333,6 +369,7 @@ class RouteMixin:
         name: Optional[str] = None,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **ctx_kwargs,
     ) -> RouteWrapper:
         """
         Add an API URL under the **PUT** *HTTP* method
@@ -345,6 +382,8 @@ class RouteMixin:
         :param name: Unique name that can be used to identify the Route
         :param version_prefix: URL path that should be before the version
             value; default: ``/v``
+        :param  ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
+            will be appended to the route context (``route.ctx``)
         :return: Object decorated with :func:`route` method
         """
         return self.route(
@@ -357,6 +396,7 @@ class RouteMixin:
             name=name,
             version_prefix=version_prefix,
             error_format=error_format,
+            **ctx_kwargs,
         )
 
     def head(
@@ -369,6 +409,7 @@ class RouteMixin:
         ignore_body: bool = True,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **ctx_kwargs,
     ) -> RouteWrapper:
         """
         Add an API URL under the **HEAD** *HTTP* method
@@ -389,6 +430,8 @@ class RouteMixin:
         :type ignore_body: bool, optional
         :param version_prefix: URL path that should be before the version
             value; default: ``/v``
+        :param  ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
+            will be appended to the route context (``route.ctx``)
         :return: Object decorated with :func:`route` method
         """
         return self.route(
@@ -401,6 +444,7 @@ class RouteMixin:
             ignore_body=ignore_body,
             version_prefix=version_prefix,
             error_format=error_format,
+            **ctx_kwargs,
         )
 
     def options(
@@ -413,6 +457,7 @@ class RouteMixin:
         ignore_body: bool = True,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **ctx_kwargs,
     ) -> RouteWrapper:
         """
         Add an API URL under the **OPTIONS** *HTTP* method
@@ -433,6 +478,8 @@ class RouteMixin:
         :type ignore_body: bool, optional
         :param version_prefix: URL path that should be before the version
             value; default: ``/v``
+        :param  ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
+            will be appended to the route context (``route.ctx``)
         :return: Object decorated with :func:`route` method
         """
         return self.route(
@@ -445,6 +492,7 @@ class RouteMixin:
             ignore_body=ignore_body,
             version_prefix=version_prefix,
             error_format=error_format,
+            **ctx_kwargs,
         )
 
     def patch(
@@ -457,6 +505,7 @@ class RouteMixin:
         name: Optional[str] = None,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **ctx_kwargs,
     ) -> RouteWrapper:
         """
         Add an API URL under the **PATCH** *HTTP* method
@@ -479,6 +528,8 @@ class RouteMixin:
         :type ignore_body: bool, optional
         :param version_prefix: URL path that should be before the version
             value; default: ``/v``
+        :param  ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
+            will be appended to the route context (``route.ctx``)
         :return: Object decorated with :func:`route` method
         """
         return self.route(
@@ -491,6 +542,7 @@ class RouteMixin:
             name=name,
             version_prefix=version_prefix,
             error_format=error_format,
+            **ctx_kwargs,
         )
 
     def delete(
@@ -503,6 +555,7 @@ class RouteMixin:
         ignore_body: bool = True,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **ctx_kwargs,
     ) -> RouteWrapper:
         """
         Add an API URL under the **DELETE** *HTTP* method
@@ -515,6 +568,8 @@ class RouteMixin:
         :param name: Unique name that can be used to identify the Route
         :param version_prefix: URL path that should be before the version
             value; default: ``/v``
+        :param  ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
+            will be appended to the route context (``route.ctx``)
         :return: Object decorated with :func:`route` method
         """
         return self.route(
@@ -527,6 +582,7 @@ class RouteMixin:
             ignore_body=ignore_body,
             version_prefix=version_prefix,
             error_format=error_format,
+            **ctx_kwargs,
         )
 
     def websocket(
@@ -540,6 +596,7 @@ class RouteMixin:
         apply: bool = True,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **ctx_kwargs,
     ):
         """
         Decorate a function to be registered as a websocket route
@@ -553,6 +610,8 @@ class RouteMixin:
                      be used with :func:`url_for`
         :param version_prefix: URL path that should be before the version
             value; default: ``/v``
+        :param  ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
+            will be appended to the route context (``route.ctx``)
         :return: tuple of routes, decorated function
         """
         return self.route(
@@ -567,6 +626,7 @@ class RouteMixin:
             websocket=True,
             version_prefix=version_prefix,
             error_format=error_format,
+            **ctx_kwargs,
         )
 
     def add_websocket_route(
@@ -580,6 +640,7 @@ class RouteMixin:
         name: Optional[str] = None,
         version_prefix: str = "/v",
         error_format: Optional[str] = None,
+        **ctx_kwargs,
     ):
         """
         A helper method to register a function as a websocket route.
@@ -598,6 +659,8 @@ class RouteMixin:
                 be used with :func:`url_for`
         :param version_prefix: URL path that should be before the version
             value; default: ``/v``
+        :param  ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
+            will be appended to the route context (``route.ctx``)
         :return: Objected decorated by :func:`websocket`
         """
         return self.websocket(
@@ -609,6 +672,7 @@ class RouteMixin:
             name=name,
             version_prefix=version_prefix,
             error_format=error_format,
+            **ctx_kwargs,
         )(handler)
 
     def static(
@@ -957,3 +1021,28 @@ class RouteMixin:
         HttpResponseVisitor().visit(node)
 
         return types
+
+    def _build_route_context(self, raw):
+        ctx_kwargs = {
+            key.replace("ctx_", ""): raw.pop(key)
+            for key in {**raw}.keys()
+            if key.startswith("ctx_")
+        }
+        restricted = [
+            key for key in ctx_kwargs.keys() if key in RESTRICTED_ROUTE_CONTEXT
+        ]
+        if restricted:
+            restricted_arguments = ", ".join(restricted)
+            raise AttributeError(
+                "Cannot use restricted route context: "
+                f"{restricted_arguments}. This limitation is only in place "
+                "until v22.3 when the restricted names will no longer be in"
+                "conflict. See https://github.com/sanic-org/sanic/issues/2303 "
+                "for more information."
+            )
+        if raw:
+            unexpected_arguments = ", ".join(raw.keys())
+            raise TypeError(
+                f"Unexpected keyword arguments: {unexpected_arguments}"
+            )
+        return HashableDict(ctx_kwargs)
diff --git a/sanic/mixins/signals.py b/sanic/mixins/signals.py
index 57b01b46..86d3b98b 100644
--- a/sanic/mixins/signals.py
+++ b/sanic/mixins/signals.py
@@ -4,11 +4,7 @@ from typing import Any, Callable, Dict, Optional, Set, Union
 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 21f9c674..f25c0270 100644
--- a/sanic/models/futures.py
+++ b/sanic/models/futures.py
@@ -7,6 +7,7 @@ from sanic.models.handler_types import (
     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 00000000..043fffb4
--- /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 00000000..e756a321
--- /dev/null
+++ b/sanic/types/hashable_dict.py
@@ -0,0 +1,3 @@
+class HashableDict(dict):
+    def __hash__(self):
+        return hash(tuple(sorted(self.items())))


Test patch:
diff --git a/tests/test_reloader.py b/tests/test_reloader.py
index e4cb63e8..d78b49a9 100644
--- a/tests/test_reloader.py
+++ b/tests/test_reloader.py
@@ -107,7 +107,7 @@ argv = dict(
         "-m",
         "sanic",
         "--port",
-        "42104",
+        "42204",
         "--debug",
         "reloader.app",
     ],
@@ -122,6 +122,7 @@ argv = dict(
         ({}, "sanic"),
     ],
 )
+@pytest.mark.xfail
 async def test_reloader_live(runargs, mode):
     with TemporaryDirectory() as tmpdir:
         filename = os.path.join(tmpdir, "reloader.py")
@@ -154,6 +155,7 @@ async def test_reloader_live(runargs, mode):
         ({}, "sanic"),
     ],
 )
+@pytest.mark.xfail
 async def test_reloader_live_with_dir(runargs, mode):
     with TemporaryDirectory() as tmpdir:
         filename = os.path.join(tmpdir, "reloader.py")
diff --git a/tests/test_routes.py b/tests/test_routes.py
index 520ab5be..3a7674c5 100644
--- a/tests/test_routes.py
+++ b/tests/test_routes.py
@@ -16,7 +16,7 @@ from sanic import Blueprint, Sanic
 from sanic.constants import HTTP_METHODS
 from sanic.exceptions import NotFound, SanicException
 from sanic.request import Request
-from sanic.response import json, text
+from sanic.response import empty, json, text
 
 
 @pytest.mark.parametrize(
@@ -1230,3 +1230,41 @@ def test_routes_with_and_without_slash_definitions(app):
         _, response = app.test_client.post(f"/{term}/")
         assert response.status == 200
         assert response.text == f"{term}_with"
+
+
+def test_added_route_ctx_kwargs(app):
+    @app.route("/", ctx_foo="foo", ctx_bar=99)
+    async def handler(request: Request):
+        return empty()
+
+    request, _ = app.test_client.get("/")
+
+    assert request.route.ctx.foo == "foo"
+    assert request.route.ctx.bar == 99
+
+
+def test_added_bad_route_kwargs(app):
+    message = "Unexpected keyword arguments: foo, bar"
+    with pytest.raises(TypeError, match=message):
+
+        @app.route("/", foo="foo", bar=99)
+        async def handler(request: Request):
+            ...
+
+
+@pytest.mark.asyncio
+async def test_added_callable_route_ctx_kwargs(app):
+    def foo(*args, **kwargs):
+        return "foo"
+
+    async def bar(*args, **kwargs):
+        return 99
+
+    @app.route("/", ctx_foo=foo, ctx_bar=bar)
+    async def handler(request: Request):
+        return empty()
+
+    request, _ = await app.asgi_client.get("/")
+
+    assert request.route.ctx.foo() == "foo"
+    assert await request.route.ctx.bar() == 99
diff --git a/tests/test_unix_socket.py b/tests/test_unix_socket.py
index b985e284..207141a9 100644
--- a/tests/test_unix_socket.py
+++ b/tests/test_unix_socket.py
@@ -5,6 +5,8 @@ import platform
 import subprocess
 import sys
 
+from string import ascii_lowercase
+
 import httpcore
 import httpx
 import pytest
@@ -13,6 +15,9 @@ from sanic import Sanic
 from sanic.response import text
 
 
+httpx_version = tuple(
+    map(int, httpx.__version__.strip(ascii_lowercase).split("."))
+)
 pytestmark = pytest.mark.skipif(os.name != "posix", reason="UNIX only")
 SOCKPATH = "/tmp/sanictest.sock"
 SOCKPATH2 = "/tmp/sanictest2.sock"
@@ -141,7 +146,10 @@ def test_unix_connection():
 
     @app.listener("after_server_start")
     async def client(app, loop):
-        transport = httpcore.AsyncConnectionPool(uds=SOCKPATH)
+        if httpx_version >= (0, 20):
+            transport = httpx.AsyncHTTPTransport(uds=SOCKPATH)
+        else:
+            transport = httpcore.AsyncConnectionPool(uds=SOCKPATH)
         try:
             async with httpx.AsyncClient(transport=transport) as client:
                 r = await client.get("http://myhost.invalid/")
@@ -186,7 +194,10 @@ async def test_zero_downtime():
     from time import monotonic as current_time
 
     async def client():
-        transport = httpcore.AsyncConnectionPool(uds=SOCKPATH)
+        if httpx_version >= (0, 20):
+            transport = httpx.AsyncHTTPTransport(uds=SOCKPATH)
+        else:
+            transport = httpcore.AsyncConnectionPool(uds=SOCKPATH)
         for _ in range(40):
             async with httpx.AsyncClient(transport=transport) as client:
                 r = await client.get("http://localhost/sleep/0.1")