Repository: Neoteroi/BlackSheep
Pull request #433 (2023-11-18 15:48:03)

Prepare for v2 stable release

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/Neoteroi/BlackSheep/pull/433?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`84ede8d`)](https://app.codecov.io/gh/Neoteroi/BlackSheep/commit/84ede8d36500ef9394e3924e644cff026dd00d2c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) 97.70% compared to head [(`95b2c65`)](https://app.codecov.io/gh/Neoteroi/BlackSheep/pull/433?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi) 97.62%.


:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #433      +/-   ##
==========================================
- Coverage   97.70%   97.62%   -0.08%     
==========================================
  Files          67       67              
  Lines        6277     6278       +1     
==========================================
- Hits         6133     6129       -4     
- Misses        144      149       +5     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/Neoteroi/BlackSheep/pull/433?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Neoteroi).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8aec923..d1f4449 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ 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.0.0] - 2023-11-18 :mage_man:
+
+- Releases v2 as stable.
+- Removes the `route` method from the `Application` class, and move it to the
+  `Router` class to be consistent with other methods to register request
+  handlers.
+- Removes `ClientConnectionPool` and `ClientConnectionPools` aliases.
+
 ## [2.0a12] - 2023-11-17 :fallen_leaf:
 
 - Adds support for Python 3.12, by @bymoye
diff --git a/README.md b/README.md
index d145893..3a9d122 100644
--- a/README.md
+++ b/README.md
@@ -24,12 +24,12 @@ pip install blacksheep
 ```python
 from datetime import datetime
 
-from blacksheep import Application
+from blacksheep import Application, get
 
 
 app = Application()
 
-@app.route("/")
+@get("/")
 async def home():
     return f"Hello, World! {datetime.utcnow().isoformat()}"
 
@@ -105,7 +105,7 @@ here](https://www.neoteroi.dev/blacksheep/requests/).
 ```python
 from dataclasses import dataclass
 
-from blacksheep import Application, FromJSON, FromQuery
+from blacksheep import Application, FromJSON, FromQuery, get, post
 
 
 app = Application()
@@ -116,21 +116,21 @@ class CreateCatInput:
     name: str
 
 
-@app.router.post("/api/cats")
+@post("/api/cats")
 async def example(data: FromJSON[CreateCatInput]):
     # in this example, data is bound automatically reading the JSON
     # payload and creating an instance of `CreateCatInput`
     ...
 
 
-@app.router.get("/:culture_code/:area")
+@get("/:culture_code/:area")
 async def home(culture_code, area):
     # in this example, both parameters are obtained from routes with
     # matching names
     return f"Request for: {culture_code} {area}"
 
 
-@app.router.get("/api/products")
+@get("/api/products")
 def get_products(
     page: int = 1,
     size: int = 30,
@@ -143,7 +143,7 @@ def get_products(
     ...
 
 
-@app.router.get("/api/products2")
+@get("/api/products2")
 def get_products2(
     page: FromQuery[int] = FromQuery(1),
     size: FromQuery[int] = FromQuery(30),
@@ -181,13 +181,13 @@ app.use_authorization()\
 
 
 @auth("admin")
-@app.router.get("/")
+@get("/")
 async def only_for_admins():
     ...
 
 
 @auth()
-@app.router.get("/")
+@get("/")
 async def only_for_authenticated_users():
     ...
 ```
diff --git a/blacksheep/__init__.py b/blacksheep/__init__.py
index 6799e8f..2ee5979 100644
--- a/blacksheep/__init__.py
+++ b/blacksheep/__init__.py
@@ -3,7 +3,7 @@ Root module of the framework. This module re-exports the most commonly
 used types to reduce the verbosity of the imports statements.
 """
 __author__ = "Roberto Prevato <roberto.prevato@gmail.com>"
-__version__ = "2.0a12"
+__version__ = "2.0.0"
 
 from .contents import Content as Content
 from .contents import FormContent as FormContent
@@ -74,6 +74,7 @@ from .server.routing import options as options
 from .server.routing import patch as patch
 from .server.routing import post as post
 from .server.routing import put as put
+from .server.routing import route as route
 from .server.routing import trace as trace
 from .server.routing import ws as ws
 from .server.websocket import WebSocket as WebSocket
diff --git a/blacksheep/baseapp.pyi b/blacksheep/baseapp.pyi
index 67eed51..fc61cf4 100644
--- a/blacksheep/baseapp.pyi
+++ b/blacksheep/baseapp.pyi
@@ -4,7 +4,7 @@ from typing import Awaitable, Callable, Dict, Optional, Type, TypeVar, Union
 from blacksheep.exceptions import HTTPException
 from blacksheep.messages import Request, Response
 from blacksheep.server.application import Application
-from blacksheep.server.routing import RouteMatch, RouterProtocol
+from blacksheep.server.routing import RouteMatch, Router
 
 ExcT = TypeVar("ExcT", bound=Exception)
 
@@ -14,7 +14,7 @@ ExceptionHandlersType = Dict[
 ]
 
 class BaseApplication:
-    def __init__(self, show_error_details: bool, router: RouterProtocol):
+    def __init__(self, show_error_details: bool, router: Router):
         self.router = router
         self.exceptions_handlers = self.init_exceptions_handlers()
         self.show_error_details = show_error_details
diff --git a/blacksheep/client/pool.py b/blacksheep/client/pool.py
index 3a56a3b..bc5c3f4 100644
--- a/blacksheep/client/pool.py
+++ b/blacksheep/client/pool.py
@@ -140,8 +140,3 @@ class ConnectionPools:
         for pool in self._pools.values():
             pool.dispose()
         self._pools.clear()
-
-
-# For backward compatibility. Aliases will be removed in the future.
-ClientConnectionPool = ConnectionPool
-ClientConnectionPools = ConnectionPools
diff --git a/blacksheep/server/application.py b/blacksheep/server/application.py
index 88e37a1..9696684 100644
--- a/blacksheep/server/application.py
+++ b/blacksheep/server/application.py
@@ -252,7 +252,7 @@ class Application(BaseApplication):
         If the child application is a BlackSheep application, it requires handling of
         its lifecycle events. This can be automatic, if the environment variable
 
-            APP_MOUNT_AUTO_EVENTS is set to "1" or "true" (case insensitive)
+            APP_MOUNT_AUTO_EVENTS is missing or set to "1" or "true" (case insensitive)
 
         or explicitly enabled, if the parent app's is configured this way:
 
@@ -443,19 +443,6 @@ class Application(BaseApplication):
         )
         return strategy
 
-    def route(
-        self, pattern: str, methods: Optional[Sequence[str]] = None
-    ) -> Callable[..., Any]:
-        if methods is None:
-            methods = ["GET"]
-
-        def decorator(f):
-            for method in methods:
-                self.router.add(method, pattern, f)
-            return f
-
-        return decorator
-
     def exception_handler(
         self, exception: Union[int, Type[Exception]]
     ) -> Callable[..., Any]:
diff --git a/blacksheep/server/controllers.py b/blacksheep/server/controllers.py
index 6cf8482..635444e 100644
--- a/blacksheep/server/controllers.py
+++ b/blacksheep/server/controllers.py
@@ -57,6 +57,7 @@ delete = router.delete
 trace = router.trace
 options = router.options
 connect = router.connect
+route = router.route
 ws = router.ws
 
 
diff --git a/blacksheep/server/cors.py b/blacksheep/server/cors.py
index fafe344..ac709fb 100644
--- a/blacksheep/server/cors.py
+++ b/blacksheep/server/cors.py
@@ -157,7 +157,7 @@ class CORSStrategy:
         using the instance of `CORSStrategy` as a function decorator:
 
         @app.cors("example")
-        @app.route("/")
+        @app.router.route("/")
         async def foo():
             ....
         """
diff --git a/blacksheep/server/env.py b/blacksheep/server/env.py
index 51ddeae..5734a54 100644
--- a/blacksheep/server/env.py
+++ b/blacksheep/server/env.py
@@ -37,5 +37,5 @@ class EnvironmentSettings:
     def __init__(self) -> None:
         self.env = get_env()
         self.show_error_details = truthy(os.environ.get("APP_SHOW_ERROR_DETAILS", ""))
-        self.mount_auto_events = truthy(os.environ.get("APP_MOUNT_AUTO_EVENTS", ""))
+        self.mount_auto_events = truthy(os.environ.get("APP_MOUNT_AUTO_EVENTS", "1"))
         self.use_default_router = truthy(os.environ.get("APP_DEFAULT_ROUTER", "1"))
diff --git a/blacksheep/server/normalization.py b/blacksheep/server/normalization.py
index bcdcc3e..1b8b8b3 100644
--- a/blacksheep/server/normalization.py
+++ b/blacksheep/server/normalization.py
@@ -112,7 +112,7 @@ def _get_method_annotations_base(method):
 # endregion
 
 
-def ensure_response(result) -> Optional[Response]:
+def ensure_response(result) -> Response:
     """
     When a request handler returns a result that is not an instance of Response,
     this method normalizes the output of the method to be either `None`. or an instance
@@ -121,8 +121,8 @@ def ensure_response(result) -> Optional[Response]:
     Use this method in custom decorators for request handlers.
     """
     if result is None:
-        # later the application defaults to 204 No Content
-        return result
+        # 204 No Content
+        return Response(204)
 
     if not isinstance(result, Response):
         # default to a plain text or JSON response
diff --git a/blacksheep/server/openapi/common.py b/blacksheep/server/openapi/common.py
index 2b73269..3e5dba0 100644
--- a/blacksheep/server/openapi/common.py
+++ b/blacksheep/server/openapi/common.py
@@ -361,7 +361,7 @@ class APIDocsHandler(Generic[OpenAPIRootType], ABC):
 
         @self.ignore()
         @allow_anonymous(self.anonymous_access)
-        @app.route(self.json_spec_path, methods=["GET", "HEAD"])
+        @app.router.route(self.json_spec_path, methods=["GET", "HEAD"])
         def get_open_api_json(request: Request):
             return get_response_for_static_content(
                 request,
@@ -373,7 +373,7 @@ class APIDocsHandler(Generic[OpenAPIRootType], ABC):
 
         @self.ignore()
         @allow_anonymous(self.anonymous_access)
-        @app.route(self.yaml_spec_path, methods=["GET", "HEAD"])
+        @app.router.route(self.yaml_spec_path, methods=["GET", "HEAD"])
         def get_open_api_yaml(request: Request):
             return get_response_for_static_content(
                 request, b"text/yaml", self._yaml_docs, current_time, cache_time=1
diff --git a/blacksheep/server/routing.py b/blacksheep/server/routing.py
index b069385..b4e2262 100644
--- a/blacksheep/server/routing.py
+++ b/blacksheep/server/routing.py
@@ -3,7 +3,7 @@ import re
 from abc import ABC, abstractmethod
 from collections import defaultdict
 from functools import lru_cache
-from typing import Any, AnyStr, Callable, Dict, List, Optional, Set, Union
+from typing import Any, AnyStr, Callable, Dict, List, Optional, Sequence, Set, Union
 from urllib.parse import unquote
 
 from blacksheep.common import extend
@@ -529,6 +529,19 @@ class RouterBase(ABC):
     def ws(self, pattern) -> Callable[..., Any]:
         return self._get_decorator(RouteMethod.GET_WS, pattern)
 
+    def route(
+        self, pattern: str, methods: Optional[Sequence[str]] = None
+    ) -> Callable[..., Any]:
+        if methods is None:
+            methods = ["GET"]
+
+        def decorator(f):
+            for method in methods:
+                self.add(method, pattern, f)
+            return f
+
+        return decorator
+
 
 class MultiRouterMixin:
     """
@@ -830,9 +843,13 @@ class MountRegistry:
 Mount = MountRegistry
 
 
-# singleton router used to store initial configuration,
+# Singleton router used to store initial configuration,
 # before the application starts
-# this is used as *default* router, but it can be overridden
+# this is used as *default* router, but it can be overridden;
+# This is done for two reasons: to reduce code verbosity when defining routes,
+# and because we can expect that in most use cases web applications use a single
+# Application and Router (the same approach can be easily used for more complex use
+# cases where more than one router is used).
 router = Router()
 
 
@@ -846,3 +863,4 @@ trace = router.trace
 options = router.options
 connect = router.connect
 ws = router.ws
+route = router.route
diff --git a/pyproject.toml b/pyproject.toml
index a1a544c..0006c52 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,19 +5,18 @@ build-backend = "setuptools.build_meta"
 [project]
 name = "blacksheep"
 dynamic = ["version"]
-authors = [
-    { name = "Roberto Prevato", email = "roberto.prevato@gmail.com" },
-]
+authors = [{ name = "Roberto Prevato", email = "roberto.prevato@gmail.com" }]
 description = "Fast web framework for Python asyncio"
 readme = "README.md"
 requires-python = ">=3.7"
 classifiers = [
-    "Development Status :: 3 - Alpha",
+    "Development Status :: 5 - Production/Stable",
     "License :: OSI Approved :: MIT License",
     "Programming Language :: Python :: 3.8",
     "Programming Language :: Python :: 3.9",
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
     "Environment :: Web Environment",
     "Operating System :: OS Independent",
     "Framework :: AsyncIO",
@@ -39,15 +38,11 @@ dependencies = [
 packages = ["blacksheep"]
 
 [tool.setuptools.dynamic]
-version = {attr = "blacksheep.__version__"}
+version = { attr = "blacksheep.__version__" }
 
 [project.optional-dependencies]
 jinja = ["Jinja2~=3.1.2"]
-full = [
-    "cryptography>=38.0.1,<41.1.0",
-    "PyJWT~=2.6.0",
-    "websockets~=10.3",
-]
+full = ["cryptography>=38.0.1,<41.1.0", "PyJWT~=2.6.0", "websockets~=10.3"]
 
 [project.urls]
 "Homepage" = "https://github.com/Neoteroi/BlackSheep"


Test patch:
diff --git a/itests/app_1.py b/itests/app_1.py
index 9c5894f..fe8bc12 100644
--- a/itests/app_1.py
+++ b/itests/app_1.py
@@ -35,17 +35,17 @@ def get_static_path(file_name):
 app.serve_files(static_folder_path, discovery=True)
 
 
-@app.route("/hello-world")
+@app.router.route("/hello-world")
 async def hello_world():
     return text("Hello, World!")
 
 
-@app.route("/plain-json")
+@app.router.route("/plain-json")
 async def plain_json():
     return json({"message": "Hello, World!"})
 
 
-@app.route("/plain-json-error-simulation")
+@app.router.route("/plain-json-error-simulation")
 async def plain_json_error_simulation():
     return json({"message": "Hello, World!"}, status=500)
 
@@ -60,13 +60,13 @@ async def echo_headers(request):
     return response
 
 
-@app.route("/echo-cookies")
+@app.router.route("/echo-cookies")
 async def echo_cookies(request):
     cookies = request.cookies
     return json(cookies)
 
 
-@app.route("/set-cookie")
+@app.router.route("/set-cookie")
 async def set_cookies(name: FromQuery[str], value: FromQuery[str]):
     response = text("Setting cookie")
     response.set_cookie(Cookie(name.value, value.value))
@@ -126,7 +126,7 @@ async def echo_route_values_autobind(one, two, three):
     return json(dict(one=one, two=two, three=three))
 
 
-@app.route("/crash")
+@app.router.route("/crash")
 async def crash():
     raise CrashTest()
 
@@ -137,7 +137,7 @@ class Item:
         self.power = power
 
 
-@app.route("/echo-posted-json-autobind", methods=["POST"])
+@app.router.route("/echo-posted-json-autobind", methods=["POST"])
 async def upload_item(request, item: Item):
     assert request is not None
     assert item is not None
diff --git a/itests/app_2.py b/itests/app_2.py
index 1930793..91f7e5d 100644
--- a/itests/app_2.py
+++ b/itests/app_2.py
@@ -161,12 +161,12 @@ async def only_for_authenticated_users():
     return None
 
 
-@app_2.route("/crash")
+@app_2.router.route("/crash")
 async def crash():
     raise CrashTest()
 
 
-@app_2.route("/handled-crash")
+@app_2.router.route("/handled-crash")
 async def handled_crash():
     raise HandledException()
 
diff --git a/itests/client_fixtures.py b/itests/client_fixtures.py
index f1e2b96..81b666a 100644
--- a/itests/client_fixtures.py
+++ b/itests/client_fixtures.py
@@ -43,7 +43,7 @@ def server_url(server_host, server_port):
 
 @pytest.fixture(scope="module")
 def session(server_url, event_loop):
-    # It is important to pass the instance of ClientConnectionPools,
+    # It is important to pass the instance of ConnectionPools,
     # to ensure that the connections are reused and closed
     session = ClientSession(
         loop=event_loop,
diff --git a/tests/test_application.py b/tests/test_application.py
index c53e84e..638abe6 100644
--- a/tests/test_application.py
+++ b/tests/test_application.py
@@ -404,7 +404,7 @@ async def test_application_middlewares_as_classes(app):
             self.calls.append(self.get_seed())
             return response
 
-    @app.route("/")
+    @app.router.route("/")
     async def example(request):
         nonlocal calls
         calls.append(5)
@@ -3494,7 +3494,7 @@ async def test_default_headers(app):
 
     assert app.default_headers == (("Example", "Foo"),)
 
-    @app.route("/")
+    @app.router.route("/")
     async def home():
         return text("Hello World")
 
diff --git a/tests/test_controllers.py b/tests/test_controllers.py
index f82aba0..d88d62f 100644
--- a/tests/test_controllers.py
+++ b/tests/test_controllers.py
@@ -3,7 +3,7 @@ from functools import wraps
 from typing import Optional
 
 import pytest
-from guardpost import User
+from guardpost import AuthenticationHandler, User
 from rodi import inject
 
 from blacksheep import Request, Response
@@ -14,6 +14,7 @@ from blacksheep.server.controllers import (
     RoutesRegistry,
     filters,
 )
+from blacksheep.server.di import register_http_context
 from blacksheep.server.responses import text
 from blacksheep.server.routing import RouteDuplicate
 from blacksheep.server.websocket import WebSocket
@@ -97,6 +98,71 @@ async def test_ws_handler_through_controller(app):
     assert called is True
 
 
+@pytest.mark.asyncio
+async def test_user_binder_with_controller(app):
+    """
+    The following test covers the scenario where the User object is first
+    bound to a request handler using the dedicated Binder (this allows to keep separated
+    runtime values like HTTP context and user from DI composition data like classes that
+    do not depend on a runtime scope), and using dependency injection (this requires
+    mixing runtime values and composition data).
+
+    In the Home controller below, the User is passed to the request handler using a
+    Binder; in the Another controller the User is instead injected using DI.
+    The second scenario requires registering the HTTP Context and User factory to obtain
+    the scoped services. The second scenario has the benefit that the User context can
+    be injected at any point of the activation chain (e.g. in the business logic layer),
+    but it has the negative side to mix runtime values with composition data.
+    """
+    app.controllers_router = RoutesRegistry()
+    get = app.controllers_router.get
+
+    class MockAuthHandler(AuthenticationHandler):
+        async def authenticate(self, context):
+            context.user = User({"name": "Dummy"}, "TEST")
+            return context.user
+
+    app.use_authentication().add(MockAuthHandler())
+    called = False
+
+    class Home(Controller):
+        @get("/1")
+        async def home(self, user: User):
+            nonlocal called
+            called = True
+            assert isinstance(self, Home)
+            assert isinstance(user, User)
+            assert user.name == "Dummy"
+
+    class Another(Controller):
+        user: User
+
+        @get("/2")
+        async def home2(self):
+            nonlocal called
+            called = True
+            assert isinstance(self, Another)
+            assert isinstance(self.user, User)
+            assert self.user.name == "Dummy"
+
+    register_http_context(app)
+
+    def user_factory(context) -> User:
+        # The following scoped service is set in a middleware, since in fact we are
+        # mixing runtime data with composition data.
+        request = context.scoped_services[Request]
+        return request.user or User()
+
+    app.services.add_scoped_by_factory(user_factory)
+
+    await app.start()
+    await app(get_example_scope("GET", "/1"), MockReceive(), MockSend())
+    assert called is True
+    called = False
+    await app(get_example_scope("GET", "/2"), MockReceive(), MockSend())
+    assert called is True
+
+
 @pytest.mark.asyncio
 @pytest.mark.parametrize(
     "path_one,path_two",
@@ -640,7 +706,7 @@ async def test_controller_with_duplicate_route_with_base_route_throw(
         async def index(self, request: Request):
             ...
 
-    @app.route(second_pattern)
+    @app.router.route(second_pattern)
     async def home():
         ...
 
diff --git a/tests/test_cors.py b/tests/test_cors.py
index c607b93..6613910 100644
--- a/tests/test_cors.py
+++ b/tests/test_cors.py
@@ -379,7 +379,7 @@ async def test_cors_preflight_request_allow_headers(app):
         allow_headers="Authorization credentials",
     )
 
-    @app.route("/", methods=["GET", "POST"])
+    @app.router.route("/", methods=["GET", "POST"])
     async def home():
         return text("Hello, World")
 
@@ -662,12 +662,12 @@ async def test_cors_by_handler(app):
         allow_origins="https://www.neoteroi.xyz",
     )
 
-    @app.route("/", methods=["GET", "POST"])
+    @app.router.route("/", methods=["GET", "POST"])
     async def home():
         return text("Hello, World")
 
     @app.cors("specific")
-    @app.route("/specific-rules", methods=["GET", "POST"])
+    @app.router.route("/specific-rules", methods=["GET", "POST"])
     async def different_rules():
         return text("Specific")
 
diff --git a/tests/test_openapi_v3.py b/tests/test_openapi_v3.py
index e65978d..53389e6 100644
--- a/tests/test_openapi_v3.py
+++ b/tests/test_openapi_v3.py
@@ -745,7 +745,7 @@ async def test_register_schema_for_multi_generic(
 ):
     app = get_app()
 
-    @app.route("/combo")
+    @app.router.route("/combo")
     def combo_example() -> Combo[Cat, Foo]:
         ...
 
@@ -829,12 +829,12 @@ async def test_register_schema_for_generic_with_list_reusing_ref(
     docs.bind_app(app)
 
     @docs(tags=["B tag"])
-    @app.route("/one")
+    @app.router.route("/one")
     def one() -> PaginatedSet[Cat]:
         ...
 
     @docs(tags=["A tag"])
-    @app.route("/two")
+    @app.router.route("/two")
     def two() -> PaginatedSet[Cat]:
         ...
 
@@ -922,7 +922,7 @@ async def test_handling_of_forward_references(
 ):
     app = get_app()
 
-    @app.route("/")
+    @app.router.route("/")
     def forward_ref_example() -> ForwardRefExample:
         ...
 
@@ -1000,7 +1000,7 @@ async def test_handling_of_normal_class(docs: OpenAPIHandler, serializer: Serial
     """
     app = get_app()
 
-    @app.route("/")
+    @app.router.route("/")
     def plain_class() -> PlainClass:
         ...
 
@@ -1039,7 +1039,7 @@ async def test_handling_of_pydantic_class_with_generic(
 ):
     app = get_app()
 
-    @app.route("/")
+    @app.router.route("/")
     def home() -> PydPaginatedSetOfCat:
         ...
 
@@ -1107,7 +1107,7 @@ async def test_handling_of_pydantic_class_with_child_models(
 ):
     app = get_app()
 
-    @app.route("/")
+    @app.router.route("/")
     def home() -> PydTypeWithChildModels:
         ...
 
@@ -1196,7 +1196,7 @@ async def test_handling_of_pydantic_class_in_generic(
 ):
     app = get_app()
 
-    @app.route("/")
+    @app.router.route("/")
     def home() -> PaginatedSet[PydCat]:
         ...
 
@@ -1262,7 +1262,7 @@ tags: []
 async def test_handling_of_sequence(docs: OpenAPIHandler, serializer: Serializer):
     app = get_app()
 
-    @app.route("/")
+    @app.router.route("/")
     def home() -> Sequence[Cat]:
         ...
 
@@ -1644,7 +1644,7 @@ tags: []
 async def test_handling_of_pydantic_types(docs: OpenAPIHandler, serializer: Serializer):
     app = get_app()
 
-    @app.route("/")
+    @app.router.route("/")
     def home() -> PydExampleWithSpecificTypes:
         ...
 
@@ -1693,7 +1693,7 @@ tags: []
 async def test_pydantic_generic(docs: OpenAPIHandler, serializer: Serializer):
     app = get_app()
 
-    @app.route("/")
+    @app.router.route("/")
     def home() -> PydResponse[PydCat]:
         ...
 
@@ -1829,7 +1829,7 @@ tags: []
 async def test_pydantic_constrained_types(docs: OpenAPIHandler, serializer: Serializer):
     app = get_app()
 
-    @app.route("/")
+    @app.router.route("/")
     def home() -> PydConstrained:
         ...
 
@@ -2031,7 +2031,7 @@ async def test_schema_registration(docs: OpenAPIHandler, serializer: Serializer)
 
     app = get_app()
 
-    @app.route("/")
+    @app.router.route("/")
     def home() -> A:
         ...
 
@@ -2041,7 +2041,7 @@ async def test_schema_registration(docs: OpenAPIHandler, serializer: Serializer)
             SecurityInfo("bearerAuth", ["read:home", "write:home"]),
         ]
     )
-    @app.route("/", methods=["POST"])
+    @app.router.route("/", methods=["POST"])
     def auth_home() -> A:
         ...
 
@@ -2110,19 +2110,19 @@ async def test_handles_ref_for_optional_type(
 ):
     app = get_app()
 
-    @app.route("/cats")
+    @app.router.route("/cats")
     def one() -> PaginatedSet[Cat]:
         ...
 
-    @app.route("/cats/{cat_id}")
+    @app.router.route("/cats/{cat_id}")
     def two(cat_id: int) -> Optional[Cat]:
         ...
 
-    @app.route("/cats_alt/{cat_id}")
+    @app.router.route("/cats_alt/{cat_id}")
     def three(cat_id: int) -> Cat:
         ...
 
-    @app.route("/cats_value_pattern/{uuid:cat_id}")
+    @app.router.route("/cats_value_pattern/{uuid:cat_id}")
     def four(cat_id: UUID) -> Cat:
         ...
 
diff --git a/tests/test_testing.py b/tests/test_testing.py
index d718a7e..b521970 100644
--- a/tests/test_testing.py
+++ b/tests/test_testing.py
@@ -42,7 +42,7 @@ def test_app():
 
 @pytest.mark.asyncio
 async def test_client_response(test_app):
-    @test_app.route("/")
+    @test_app.router.route("/")
     async def home(request):
         return {"foo": "bar"}
 
@@ -63,7 +63,7 @@ async def test_client_headers(test_app):
     class FromTestHeader(FromHeader[str]):
         name = "test_header"
 
-    @test_app.route("/")
+    @test_app.router.route("/")
     async def home(request, test_header: FromTestHeader):
         return test_header.value
 
@@ -80,7 +80,7 @@ async def test_client_headers(test_app):
 
 @pytest.mark.asyncio
 async def test_client_content(test_app):
-    @test_app.route("/", methods=["POST"])
+    @test_app.router.route("/", methods=["POST"])
     async def home(request):
         json_data = await request.json()
         return json_data
@@ -107,7 +107,7 @@ async def test_client_content(test_app):
 )
 @pytest.mark.asyncio
 async def test_client_queries(test_app, input_query):
-    @test_app.route("/")
+    @test_app.router.route("/")
     async def home(request):
         return request.query
 
@@ -131,7 +131,7 @@ async def test_client_queries(test_app, input_query):
 )
 @pytest.mark.asyncio
 async def test_client_cookies(test_app, input_cookies):
-    @test_app.route("/")
+    @test_app.router.route("/")
     async def home(request):
         return request.cookies
 
@@ -187,7 +187,7 @@ async def test_custom_test_simulator(test_app):
 )
 @pytest.mark.asyncio
 async def test_client_methods(test_app, method, expected_method):
-    @test_app.route("/", methods=[method])
+    @test_app.router.route("/", methods=[method])
     async def home(request):
         return request.method